├── videos └── .gitignore ├── .gitchangelog.rc ├── src └── videowall │ ├── player │ ├── player_exceptions.py │ ├── assets │ │ └── error.jpg │ ├── __init__.py │ ├── player_platforms.py │ ├── player_server.py │ └── player_client.py │ ├── networking │ ├── networking_exceptions.py │ ├── __init__.py │ ├── message_definition.py │ ├── networking_server.py │ └── networking_client.py │ ├── media_manager │ ├── media_manager_exceptions.py │ ├── __init__.py │ ├── media_manager_client.py │ ├── media_manager.py │ └── media_manager_server.py │ ├── gi_version.py │ ├── __init__.py │ ├── util.py │ ├── client.py │ ├── server.py │ └── web_server.py ├── web ├── babel.config.js ├── dist │ ├── favicon.ico │ ├── img │ │ └── bigbunny.6802aeac.jpg │ ├── index.html │ ├── css │ │ └── app.f564cdc6.css │ └── js │ │ ├── app.7e10ce6b.js │ │ └── app.7e10ce6b.js.map ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── assets │ │ ├── logo.png │ │ └── bigbunny.jpg │ ├── App.vue │ ├── filters.js │ ├── components │ │ ├── NavBar.vue │ │ ├── Player.vue │ │ ├── PlayerBar.vue │ │ ├── PlaylistModal.vue │ │ └── ScreenGrid.vue │ └── main.js ├── vue.config.js ├── .gitignore ├── README.md └── package.json ├── .travis.yml ├── doc └── example_2monitor.gif ├── requirements.txt ├── setup.bash ├── cfg └── rpi │ ├── boot │ └── config.txt │ └── etc │ ├── systemd │ └── system │ │ └── videowall.service │ └── rc.local ├── CHANGELOG.md ├── test ├── test_media_manager_server ├── test_networking_client └── test_networking_server ├── install_ubuntu_x86.bash ├── scripts ├── player_server ├── client ├── player_client └── web_server ├── .gitignore ├── install_raspberry_pi_stretch_lite_autostart.bash └── README.md /videos/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | output_engine = mustache("markdown") 2 | -------------------------------------------------------------------------------- /src/videowall/player/player_exceptions.py: -------------------------------------------------------------------------------- 1 | class PlayerException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /web/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /web/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/web/dist/favicon.ico -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: generic 3 | script: 4 | - ./install_ubuntu_x86.bash 5 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /doc/example_2monitor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/doc/example_2monitor.gif -------------------------------------------------------------------------------- /web/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/web/src/assets/logo.png -------------------------------------------------------------------------------- /src/videowall/networking/networking_exceptions.py: -------------------------------------------------------------------------------- 1 | class NetworkingException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /web/src/assets/bigbunny.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/web/src/assets/bigbunny.jpg -------------------------------------------------------------------------------- /src/videowall/media_manager/media_manager_exceptions.py: -------------------------------------------------------------------------------- 1 | class MediaManagerException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /web/dist/img/bigbunny.6802aeac.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/web/dist/img/bigbunny.6802aeac.jpg -------------------------------------------------------------------------------- /src/videowall/player/assets/error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reinzor/videowall/HEAD/src/videowall/player/assets/error.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tqdm==4.26.0 2 | pymediainfo==2.3.0 3 | colorlog==3.1.4 4 | tornado==5.1.1 5 | gitchangelog==3.0.4 6 | pystache==0.5.4 7 | -------------------------------------------------------------------------------- /src/videowall/networking/__init__.py: -------------------------------------------------------------------------------- 1 | from .networking_client import NetworkingClient 2 | from .networking_server import NetworkingServer 3 | -------------------------------------------------------------------------------- /src/videowall/media_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from .media_manager_server import MediaManagerServer 2 | from .media_manager_client import MediaManagerClient 3 | -------------------------------------------------------------------------------- /setup.bash: -------------------------------------------------------------------------------- 1 | export PYTHONPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"/src 2 | export GST_DEBUG=1 3 | export GST_PLUGIN_PATH=/usr/local/lib/gstreamer-1.0:/usr/lib/arm-linux-gnueabihf/gstreamer-1.0 4 | -------------------------------------------------------------------------------- /src/videowall/player/__init__.py: -------------------------------------------------------------------------------- 1 | from .player_client import PlayerClient 2 | from .player_server import PlayerServer 3 | from .player_platforms import PlayerPlatform, get_player_platform_strings, player_platform_from_string 4 | -------------------------------------------------------------------------------- /cfg/rpi/boot/config.txt: -------------------------------------------------------------------------------- 1 | hdmi_safe=1 # maximum hdmi compatibility and enforce 640x480@60Hz (our playback is max 720p and we want multiple displays so this seems ok) 2 | gpu_mem = 128MB # Not sure whether this really affects anything ... 3 | -------------------------------------------------------------------------------- /src/videowall/gi_version.py: -------------------------------------------------------------------------------- 1 | import gi 2 | 3 | gi.require_version('GLib', '2.0') 4 | gi.require_version('Gst', '1.0') 5 | gi.require_version('GstNet', '1.0') 6 | 7 | from gi.repository import GLib, Gst, GstNet, GObject 8 | 9 | _ = Gst, GstNet, GObject, GLib 10 | -------------------------------------------------------------------------------- /web/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | proxy: { 4 | '^/ws': { 5 | ws: true, 6 | target: 'http://localhost:3000' 7 | }, 8 | '^/upload': { 9 | target: 'http://localhost:3000' 10 | }, 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cfg/rpi/etc/systemd/system/videowall.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Videowall 3 | After=getty.target 4 | 5 | [Install] 6 | WantedBy=multi-user.target 7 | 8 | [Service] 9 | User=pi 10 | ExecStart=/bin/bash -c "source /home/pi/videowall/setup.bash && sleep 5 && /home/pi/videowall/scripts/client rpi eth0" 11 | 12 | -------------------------------------------------------------------------------- /src/videowall/media_manager/media_manager_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .media_manager import MediaManager 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class MediaManagerClient(MediaManager): 9 | def __init__(self, base_path): 10 | super(MediaManagerClient, self).__init__(base_path) 11 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | 21 | 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1 (2019-02-15) 4 | 5 | ### New 6 | 7 | * Added time and ip overlay for testing purposes [Rein Appeldoorn] 8 | 9 | * Web server for distributing video files and starting/stopping videos [Rein Appeldoorn] 10 | 11 | * Videocrop configs per client [Rein Appeldoorn] 12 | 13 | * Synchronized playback on multiple clients with central time server [Rein Appeldoorn] 14 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # videowall 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | -------------------------------------------------------------------------------- /web/src/filters.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | 3 | Vue.filter("hoursMinutesSeconds", function(value) { 4 | let hours = parseInt(Math.floor(value / 3600)); 5 | let minutes = parseInt(Math.floor((value - (hours * 3600)) / 60)); 6 | let seconds= parseInt((value - ((hours * 3600) + (minutes * 60))) % 60); 7 | 8 | let dHours = (hours > 9 ? hours : '0' + hours); 9 | let dMins = (minutes > 9 ? minutes : '0' + minutes); 10 | let dSecs = (seconds > 9 ? seconds : '0' + seconds); 11 | 12 | return dHours + ":" + dMins + ":" + dSecs; 13 | }) -------------------------------------------------------------------------------- /src/videowall/__init__.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | 3 | logging.config.dictConfig({ 4 | 'version': 1, 5 | 'disable_existing_loggers': False, 6 | 'formatters': { 7 | 'colored': { 8 | '()': 'colorlog.ColoredFormatter', 9 | 'format': "%(log_color)s[%(levelname)s] %(name)s: %(message)s", 10 | } 11 | }, 12 | 'handlers': { 13 | 'stream': { 14 | 'class': 'logging.StreamHandler', 15 | 'formatter': 'colored', 16 | }, 17 | }, 18 | 'root': { 19 | 'handlers': ['stream'], 20 | 'level': 'INFO', 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /test/test_media_manager_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | from argparse import ArgumentParser 4 | 5 | from videowall.media_manager import MediaManagerServer 6 | 7 | 8 | if __name__ == '__main__': 9 | parser = ArgumentParser() 10 | 11 | parser.add_argument('remotes', nargs="+") 12 | 13 | parser.add_argument('--base_path', default='~/Videos') 14 | 15 | args = parser.parse_args() 16 | 17 | logging.getLogger().setLevel(logging.DEBUG) 18 | 19 | manager = MediaManagerServer(args.base_path) 20 | logging.info("Filenames: %s", manager.get_filenames()) 21 | manager.sync(args.remotes) 22 | -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | videowall 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | 27 | -------------------------------------------------------------------------------- /cfg/rpi/etc/rc.local: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ ! -f /etc/network/mac ]; then 4 | mac=`echo -n 00:60:2F; dd bs=1 count=3 if=/dev/random 2>/dev/null |hexdump -v -e '/1 ":%02X"'` 5 | 6 | echo -e " auto lo\n"\ 7 | "iface lo inet loopback\n"\ 8 | "\n"\ 9 | "allow-hotplug eth0\n"\ 10 | "iface eth0 inet dhcp\n"\ 11 | " hwaddress ether ${mac}\n" > /etc/network/interfaces 12 | 13 | echo $mac > /etc/network/mac 14 | echo "Changed the MAC address to ${mac} , rebooting ..." 15 | 16 | reboot 17 | fi 18 | 19 | # Print the IP address 20 | _IP=$(hostname -I) || true 21 | if [ "$_IP" ]; then 22 | printf "My IP address is %s\n" "$_IP" 23 | fi 24 | 25 | exit 0 26 | -------------------------------------------------------------------------------- /web/dist/index.html: -------------------------------------------------------------------------------- 1 | videowall
-------------------------------------------------------------------------------- /install_ubuntu_x86.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Install system dependencies 4 | sudo apt-get -y update 5 | sudo apt-get -y install git \ 6 | gstreamer1.0-plugins-base-apps \ 7 | gstreamer1.0-plugins-good \ 8 | gstreamer1.0-plugins-bad \ 9 | gstreamer1.0-libav ubuntu-restricted-extras \ 10 | libmediainfo-dev \ 11 | python-pip 12 | 13 | # Install python dependencies 14 | sudo -H pip install -r requirements.txt 15 | 16 | # Create videos folder and download sample video 17 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 18 | wget https://github.com/reinzor/videowall/releases/download/0/big_buck_bunny_720p_30mb.mp4 -O $SCRIPT_DIR/videos/big_buck_bunny_720p_30mb.mp4 19 | 20 | # Setup paths in bashrc 21 | echo "source $SCRIPT_DIR/setup.bash" >> ~/.bashrc 22 | -------------------------------------------------------------------------------- /web/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import VueRouter from 'vue-router' 4 | Vue.use(VueRouter) 5 | 6 | import BootstrapVue from 'bootstrap-vue' 7 | Vue.use(BootstrapVue); 8 | import 'bootstrap/dist/css/bootstrap.css' 9 | import 'bootstrap-vue/dist/bootstrap-vue.css' 10 | 11 | import Icon from 'vue-awesome/components/Icon' 12 | import 'vue-awesome/icons' 13 | Vue.component('v-icon', Icon) 14 | 15 | import VueNativeSock from 'vue-native-websocket' 16 | Vue.use(VueNativeSock, "ws://" + location.host + "/ws", { 17 | format: 'json', 18 | reconnection: true 19 | }) 20 | 21 | import "./filters" 22 | 23 | import App from './App.vue' 24 | import Player from './components/Player.vue' 25 | 26 | Vue.config.productionTip = false 27 | 28 | const routes = [ 29 | { path: '/', component: Player } 30 | ] 31 | 32 | const router = new VueRouter({ 33 | routes 34 | }) 35 | 36 | new Vue({ 37 | router, 38 | render: h => h(App) 39 | }).$mount('#app') 40 | -------------------------------------------------------------------------------- /src/videowall/player/player_platforms.py: -------------------------------------------------------------------------------- 1 | from .player_exceptions import PlayerException 2 | 3 | 4 | class PlayerPlatform(object): 5 | pass 6 | 7 | 8 | class PlayerPlatformPC(PlayerPlatform): 9 | pass 10 | 11 | 12 | class PlayerPlatformRaspberryPi(PlayerPlatform): 13 | pass 14 | 15 | 16 | _string_player_platform_map = { 17 | "pc": PlayerPlatformPC, 18 | "rpi": PlayerPlatformRaspberryPi 19 | } 20 | 21 | _platform_player_string_map = { 22 | PlayerPlatformPC: "pc", 23 | PlayerPlatformRaspberryPi: "rpi" 24 | } 25 | 26 | 27 | def player_platform_from_string(string): 28 | try: 29 | platform = _string_player_platform_map[string] 30 | except KeyError as e: 31 | raise PlayerException(e) 32 | else: 33 | return platform 34 | 35 | 36 | def string_from_player_platform(player_platform): 37 | try: 38 | string = _platform_player_string_map[player_platform] 39 | except KeyError as e: 40 | raise PlayerException(e) 41 | else: 42 | return string 43 | 44 | 45 | def get_player_platform_strings(): 46 | return _string_player_platform_map.keys() 47 | 48 | 49 | def get_player_platforms(): 50 | return _string_player_platform_map.values() 51 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "videowall", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "bootstrap-vue": "^2.0.0-rc.11", 12 | "debounce": "^1.2.0", 13 | "vue": "^2.5.17", 14 | "vue-awesome": "^3.1.3", 15 | "vue-grid-layout": "^2.2.0", 16 | "vue-native-websocket": "^2.0.12", 17 | "vue-router": "^3.0.1", 18 | "vue2-dropzone": "^3.5.2", 19 | "vuedraggable": "^2.16.0" 20 | }, 21 | "devDependencies": { 22 | "@vue/cli-plugin-babel": "^3.0.4", 23 | "@vue/cli-plugin-eslint": "^3.0.4", 24 | "@vue/cli-service": "^3.0.4", 25 | "vue-template-compiler": "^2.5.17" 26 | }, 27 | "eslintConfig": { 28 | "root": true, 29 | "env": { 30 | "node": true 31 | }, 32 | "extends": [ 33 | "plugin:vue/essential", 34 | "eslint:recommended" 35 | ], 36 | "rules": {}, 37 | "parserOptions": { 38 | "parser": "babel-eslint" 39 | } 40 | }, 41 | "postcss": { 42 | "plugins": { 43 | "autoprefixer": {} 44 | } 45 | }, 46 | "browserslist": [ 47 | "> 1%", 48 | "last 2 versions", 49 | "not ie <= 8" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /test/test_networking_client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import logging 4 | from argparse import ArgumentParser 5 | from videowall.networking import NetworkingClient 6 | from videowall.util import validate_positive_int_argument 7 | 8 | if __name__ == '__main__': 9 | parser = ArgumentParser() 10 | 11 | parser.add_argument('--ip', default='127.0.0.1') 12 | parser.add_argument('--server_broadcast_port', type=validate_positive_int_argument, default=2000) 13 | parser.add_argument('--server_play_broadcast_port', type=validate_positive_int_argument, default=2001) 14 | parser.add_argument('--client_broadcast_port', type=validate_positive_int_argument, default=3000) 15 | parser.add_argument('--client_broadcast_interval', type=validate_positive_int_argument, default=1) 16 | parser.add_argument('--buffer_size', type=validate_positive_int_argument, default=1024) 17 | 18 | args = parser.parse_args() 19 | 20 | logging.getLogger().setLevel(logging.DEBUG) 21 | 22 | client = NetworkingClient(args.ip, args.server_broadcast_port, args.client_broadcast_port, args.buffer_size) 23 | 24 | client.receive_server_broadcast() 25 | 26 | try: 27 | while True: 28 | client.receive_server_play_broadcast() 29 | except KeyboardInterrupt: 30 | client.close() 31 | -------------------------------------------------------------------------------- /web/src/components/Player.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /src/videowall/media_manager/media_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from .media_manager_exceptions import MediaManagerException 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class MediaManager(object): 10 | def __init__(self, base_path, extensions=["mp4"]): 11 | real_base_path = os.path.realpath(os.path.expanduser(base_path)) 12 | 13 | if not os.path.isdir(real_base_path): 14 | raise MediaManagerException("Specified base path %s does not exist", base_path) 15 | 16 | self._base_path = real_base_path 17 | self._extensions = extensions 18 | 19 | def _is_valid_filename(self, filename): 20 | return "." in filename and filename.split(".")[-1] in self._extensions and " " not in filename 21 | 22 | def get_media_path(self): 23 | return self._base_path 24 | 25 | def get_full_path(self, filename): 26 | return os.path.join(self._base_path, filename) 27 | 28 | def get_filenames(self): 29 | return [filename for filename in os.listdir(self._base_path) if self._is_valid_filename(filename)] 30 | 31 | def get_extensions(self): 32 | return self._extensions 33 | 34 | def delete_media(self, filename): 35 | path = self.get_full_path(filename) 36 | if os.path.exists(path): 37 | os.remove(path) 38 | else: 39 | raise MediaManagerException("Media filename {} does not exist!".format(filename)) 40 | -------------------------------------------------------------------------------- /scripts/player_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import logging 4 | import time 5 | 6 | from tqdm import tqdm 7 | from videowall.player import PlayerServer 8 | from videowall.util import validate_positive_int_argument, validate_ip 9 | 10 | if __name__ == '__main__': 11 | parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 12 | parser.add_argument('filename') 13 | parser.add_argument('--base_time_offset', type=validate_positive_int_argument, default=10) 14 | parser.add_argument('--spin_rate', type=validate_positive_int_argument, default=100) 15 | parser.add_argument('--ip', type=validate_ip, default='127.0.0.1') 16 | parser.add_argument('--port', type=validate_positive_int_argument, default=11111) 17 | parser.add_argument('--verbose', action='store_true') 18 | 19 | args = parser.parse_args() 20 | 21 | if args.verbose: 22 | logging.getLogger().setLevel(logging.DEBUG) 23 | 24 | player = PlayerServer(args.ip, args.port) 25 | 26 | player.play(args.filename, args.base_time_offset) 27 | 28 | logging.info("Playing file %s", args.filename) 29 | 30 | with tqdm(total=player.get_duration(), bar_format='Playing: {l_bar}{bar} | {n_fmt}/{total_fmt}') as progress_bar: 31 | while player.is_playing(): 32 | progress_bar.update(player.get_position() - progress_bar.n) 33 | try: 34 | time.sleep(1. / args.spin_rate) 35 | except KeyboardInterrupt: 36 | break 37 | -------------------------------------------------------------------------------- /web/dist/css/app.f564cdc6.css: -------------------------------------------------------------------------------- 1 | #playlistModal .list-group-item{padding:10px}.playlistVideoButtons{margin:0!important;padding:0!important}.playlistVideoButtons button{padding:0 5px!important}#playlistModal #dropzone{padding:0;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}#playlistModal .dropzone{margin-top:10px;min-height:0}#playlistModal .dz-details{background-color:#17a2b8;border-radius:10px}#playlistModal .dz-image{height:100px}#playlistModal .dz-progress{opacity:.8;margin-top:-2px;width:80px;height:10px}#playlistModal .modal-dialog{max-width:80%}#playerBar{background-color:rgba(0,0,0,.03);border-left:1px solid rgba(0,0,0,.125);border-right:1px solid rgba(0,0,0,.125);border-bottom:1px solid rgba(0,0,0,.125);width:1280px}#playerBar .btn-group{margin:5px}#playerProgress{display:inline-block;width:882px}#playerProgress .progress{height:38px;margin:5px;text-align:center}#playerProgress .progress-bar{padding:15px}#playerDuration{width:100px}#playerDuration,.playerTime{display:inline-block;margin:5px}.playerTime{width:90px}#screenGrid>.card-body{padding:0}#screenGrid>.card-header{padding:5px}#screenGridLayout{background-image:url(../img/bigbunny.6802aeac.jpg);background-repeat:repeat-y}.screen .card{background:none;height:100%;border:1px solid hsla(0,0%,100%,.8)}.screen .card-header{background-color:hsla(0,0%,100%,.5);font-size:10px;color:grey;padding:0}.screen .card-header div{padding:0!important;margin:0!important}.screen .card-header button{padding:0 5px}.screen .card-header span{margin:3px;line-height:23px}.screen .card-footer{background-color:hsla(0,0%,100%,.5);font-size:10px;color:grey;padding:4px 3px}.clientTable{font-size:10px}#videoWall{padding-top:20px} -------------------------------------------------------------------------------- /scripts/client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import logging 4 | import os 5 | from argparse import ArgumentParser 6 | 7 | from videowall.client import Client 8 | from videowall.player import get_player_platform_strings, player_platform_from_string 9 | from videowall.util import ip_from_ifname, validate_positive_int_argument, validate_positive_float_argument, get_ifnames 10 | 11 | if __name__ == '__main__': 12 | parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 13 | parser.add_argument('player_platform', choices=get_player_platform_strings()) 14 | parser.add_argument('ifname', choices=get_ifnames()) 15 | parser.add_argument('--media_path', default=os.path.join(os.path.dirname(os.path.realpath(__file__)), "../videos")) 16 | parser.add_argument('--verbose', action='store_true') 17 | parser.add_argument('--server_broadcast_port', type=validate_positive_int_argument, default=2000) 18 | parser.add_argument('--server_play_broadcast_port', type=validate_positive_int_argument, default=2001) 19 | parser.add_argument('--client_broadcast_port', type=validate_positive_int_argument, default=3000) 20 | parser.add_argument('--client_broadcast_interval', type=validate_positive_float_argument, default=1.) 21 | 22 | args = parser.parse_args() 23 | 24 | if args.verbose: 25 | logging.getLogger().setLevel(logging.DEBUG) 26 | 27 | client = Client(player_platform_from_string(args.player_platform), args.media_path, ip_from_ifname(args.ifname), 28 | args.server_broadcast_port, args.server_play_broadcast_port, args.client_broadcast_port, 29 | args.client_broadcast_interval) 30 | try: 31 | client.run() 32 | except KeyboardInterrupt: 33 | client.close() 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .idea 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /src/videowall/media_manager/media_manager_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import multiprocessing 3 | import os 4 | import subprocess 5 | import time 6 | from functools import partial 7 | 8 | from .media_manager import MediaManager 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def _rsync(local_filenames, remote_path): 14 | cmd = 'rsync -avzP {} {}'.format(" ".join(local_filenames), remote_path) 15 | logger.debug('rsync command: %s', cmd) 16 | t_start = time.time() 17 | with open(os.devnull, 'w') as DEVNULL: 18 | subprocess.call( 19 | cmd.split(' '), 20 | stdout=DEVNULL, 21 | stderr=subprocess.STDOUT 22 | ) 23 | logger.debug('rsync to remote path %s took %.3f seconds', remote_path, time.time() - t_start) 24 | 25 | 26 | class MediaManagerServer(MediaManager): 27 | def __init__(self, base_path, num_sync_processes=10): 28 | super(MediaManagerServer, self).__init__(base_path) 29 | self._num_sync_processes = num_sync_processes 30 | 31 | def _sync_many(self, remote_paths): 32 | logger.info('rsync to remote paths %s', remote_paths) 33 | 34 | t_start = time.time() 35 | pool = multiprocessing.Pool(processes=self._num_sync_processes) 36 | pool.map(partial(_rsync, [self.get_full_path(f) for f in self.get_filenames()]), remote_paths) 37 | logger.info('rsync to %d remotes with %d processes took %.3f seconds', 38 | len(remote_paths), self._num_sync_processes, time.time() - t_start) 39 | 40 | def sync(self, remote_path): 41 | if isinstance(remote_path, list): 42 | remote_paths = remote_path 43 | else: 44 | remote_paths = [remote_path] 45 | 46 | self._sync_many(remote_paths) 47 | 48 | def add_file(self, path): 49 | pass 50 | 51 | def remove_file(self, name): 52 | pass 53 | -------------------------------------------------------------------------------- /test/test_networking_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import logging 4 | import time 5 | from argparse import ArgumentParser 6 | from videowall.networking import NetworkingServer 7 | from videowall.networking.message_definition import ServerPlayBroadcastMessage, ClientConfig, ServerBroadcastMessage 8 | from videowall.util import validate_positive_int_argument 9 | 10 | if __name__ == '__main__': 11 | parser = ArgumentParser() 12 | 13 | parser.add_argument('--filename', default='file.mp4') 14 | parser.add_argument('--ip', default='127.0.0.1') 15 | parser.add_argument('--clock_port', type=validate_positive_int_argument, default=11111) 16 | parser.add_argument('--server_broadcast_port', type=validate_positive_int_argument, default=2000) 17 | parser.add_argument('--server_play_broadcast_port', type=validate_positive_int_argument, default=2001) 18 | parser.add_argument('--server_broadcast_interval', type=float, default=1.0) 19 | parser.add_argument('--client_broadcast_port', type=validate_positive_int_argument, default=3000) 20 | parser.add_argument('--client_config', type=json.loads, default='{}') 21 | parser.add_argument('--buffer_size', type=validate_positive_int_argument, default=1024) 22 | 23 | args = parser.parse_args() 24 | 25 | logging.getLogger().setLevel(logging.DEBUG) 26 | 27 | logging.info("Client config: %s", args.client_config) 28 | 29 | server = NetworkingServer(args.server_broadcast_port, args.client_broadcast_port, args.buffer_size) 30 | try: 31 | while True: 32 | server.send_broadcast(ServerBroadcastMessage( 33 | clock_ip=args.ip, 34 | clock_port=args.clock_port 35 | )) 36 | server.send_play_broadcast(ServerPlayBroadcastMessage( 37 | filename=args.filename, 38 | base_time_nsecs=int(time.time() / 1e-9), 39 | time_overlay=True, 40 | client_config={ip: ClientConfig(**cfg) for ip, cfg in args.client_config.items()} 41 | )) 42 | time.sleep(args.server_broadcast_interval) 43 | except KeyboardInterrupt: 44 | server.close() 45 | -------------------------------------------------------------------------------- /scripts/player_client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import logging 4 | import time 5 | 6 | from tqdm import tqdm 7 | from videowall.networking.message_definition import VideocropConfig 8 | from videowall.player import PlayerClient, get_player_platform_strings, player_platform_from_string 9 | from videowall.player.player_exceptions import PlayerException 10 | from videowall.util import validate_positive_int_argument 11 | 12 | if __name__ == '__main__': 13 | parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 14 | parser.add_argument('platform', choices=get_player_platform_strings()) 15 | parser.add_argument('filename') 16 | parser.add_argument('base_time', type=validate_positive_int_argument) 17 | parser.add_argument('--spin_rate', type=validate_positive_int_argument, default=100) 18 | parser.add_argument('--ip', default='127.0.0.1') 19 | parser.add_argument('--port', type=validate_positive_int_argument, default=11111) 20 | parser.add_argument('--verbose', action='store_true') 21 | parser.add_argument('--text_overlay', default='') 22 | parser.add_argument('--time_overlay', action='store_true') 23 | parser.add_argument('--use_local_clock', action='store_true') 24 | 25 | args = parser.parse_args() 26 | 27 | if args.verbose: 28 | logging.getLogger().setLevel(logging.DEBUG) 29 | 30 | player = PlayerClient(player_platform_from_string(args.platform), args.ip, args.port, args.use_local_clock) 31 | 32 | try: 33 | player.play(args.filename, args.base_time, VideocropConfig(200, 20, 0, 0), args.text_overlay, args.time_overlay) 34 | except PlayerException as e: 35 | logging.fatal(e) 36 | else: 37 | logging.info("Playing file %s with base time %d", args.filename, args.base_time) 38 | 39 | with tqdm(total=player.get_duration(), 40 | bar_format='Playing: {l_bar}{bar} | {n_fmt}/{total_fmt}') as progress_bar: 41 | while player.is_playing(): 42 | progress_bar.update(player.get_position() - progress_bar.n) 43 | try: 44 | time.sleep(1. / args.spin_rate) 45 | except KeyboardInterrupt: 46 | break 47 | 48 | player.close() 49 | -------------------------------------------------------------------------------- /web/src/components/PlayerBar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 44 | 79 | -------------------------------------------------------------------------------- /install_raspberry_pi_stretch_lite_autostart.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 3 | 4 | # Install system dependencies 5 | sudo apt-get -y update 6 | sudo apt-get -y install git \ 7 | libgstreamer-plugins-base1.0-dev \ 8 | gstreamer1.0-plugins-base-apps \ 9 | gstreamer1.0-tools \ 10 | gstreamer1.0-plugins-good \ 11 | gstreamer1.0-plugins-bad \ 12 | python-pip 13 | 14 | # Install python dependencies 15 | sudo -H pip install -r requirements.txt 16 | 17 | # Build & Install Gstreamer MMAL 18 | git clone https://github.com/reinzor/gst-mmal /tmp/gst-mmal 19 | cd /tmp/gst-mmal 20 | LDFLAGS='-L/opt/vc/lib' CPPFLAGS='-I/opt/vc/include -I/opt/vc/include/interface/vcos/pthreads -I/opt/vc/include/interface/vmcs_host/linux' ./autogen.sh --disable-gtk-doc 21 | make 22 | sudo make install 23 | 24 | # Build & Install Gstreamer OMX 25 | git clone https://github.com/reinzor/gst-omx /tmp/gst-omx 26 | cd /tmp/gst-omx 27 | git checkout 1.10.4 28 | LDFLAGS='-L/opt/vc/lib' CFLAGS='-I/opt/vc/include -I/opt/vc/include/IL -I/opt/vc/include/interface/vcos/pthreads -I/opt/vc/include/interface/vmcs_host/linux -I/opt/vc/include/IL' CPPFLAGS='-I/opt/vc/include -I/opt/vc/include/IL -I/opt/vc/include/interface/vcos/pthreads -I/opt/vc/include/interface/vmcs_host/linux -I/opt/vc/include/IL' ./autogen.sh --disable-gtk-doc --with-omx-target=rpi 29 | make 30 | sudo make install 31 | 32 | # Create videos folder and download sample video 33 | wget https://github.com/reinzor/videowall/releases/download/0/big_buck_bunny_720p_30mb.mp4 -O $SCRIPT_DIR/videos/big_buck_bunny_720p_30mb.mp4 34 | 35 | # Setup paths in bashrc 36 | echo "source $SCRIPT_DIR/setup.bash" >> ~/.bashrc 37 | 38 | # Copy additional raspberry pi system files 39 | sudo rm /etc/network/mac 40 | sudo cp -r $SCRIPT_DIR/cfg/rpi/* / 41 | 42 | # Enable startup run and disable dhcpcd 43 | sudo systemctl daemon-reload 44 | sudo systemctl enable videowall 45 | sudo systemctl disable dhcpcd 46 | 47 | # Show installation done message 48 | echo "=======" 49 | echo "" 50 | echo "Installation complete, please source your ~/.bashrc and type the following command:" 51 | echo "" 52 | echo " gst-launch-1.0 -v filesrc location=$SCRIPT_DIR/videos/big_buck_bunny_720p_30mb.mp4 ! qtdemux ! h264parse ! omxh264dec ! videocrop bottom=10 ! mmalvideosink" 53 | echo "" 54 | echo "======" 55 | -------------------------------------------------------------------------------- /src/videowall/networking/message_definition.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from videowall.util import to_dict 4 | 5 | from .networking_exceptions import NetworkingException 6 | 7 | 8 | class Message(object): 9 | def to_dict(self): 10 | return to_dict(self) 11 | 12 | def __repr__(self): 13 | return '{class_name}({params})'.format( 14 | class_name=self.__class__.__name__, 15 | params=', '.join('{}={}'.format(k, v) for k, v in vars(self).items())) 16 | 17 | 18 | class VideocropConfig(Message): 19 | def __init__(self, bottom, left, right, top): 20 | self.bottom = int(bottom) 21 | self.left = int(left) 22 | self.right = int(right) 23 | self.top = int(top) 24 | 25 | @staticmethod 26 | def get_default(): 27 | return VideocropConfig(0, 0, 0, 0) 28 | 29 | 30 | class ClientConfig(Message): 31 | def __init__(self, videocrop_config): 32 | self.videocrop_config = VideocropConfig(**videocrop_config) 33 | 34 | @staticmethod 35 | def get_default(): 36 | return ClientConfig(VideocropConfig.get_default().to_dict()) 37 | 38 | 39 | class ServerPlayBroadcastMessage(Message): 40 | def __init__(self, filename, base_time_nsecs, time_overlay, client_config): 41 | try: 42 | self.filename = filename 43 | self.base_time_nsecs = int(base_time_nsecs) 44 | self.time_overlay = time_overlay 45 | 46 | if not isinstance(client_config, dict): 47 | raise NetworkingException("The client config should be a dictionary") 48 | 49 | self.client_config = {} 50 | for ip, cfg in client_config.items(): 51 | if not isinstance(cfg, dict): 52 | raise NetworkingException("Client config entry should be of dictionary") 53 | try: 54 | socket.inet_pton(socket.AF_INET, ip) 55 | except socket.error as e: 56 | raise NetworkingException(e) 57 | self.client_config[ip] = ClientConfig(**cfg) 58 | except Exception as e: 59 | raise NetworkingException(e) 60 | 61 | 62 | class ServerBroadcastMessage(Message): 63 | def __init__(self, clock_ip, clock_port): 64 | try: 65 | self.clock_ip = clock_ip 66 | self.clock_port = int(clock_port) 67 | except Exception as e: 68 | raise NetworkingException(e) 69 | 70 | 71 | class ClientBroadcastMessage(Message): 72 | def __init__(self, username, ip, media_path): 73 | self.username = username 74 | self.ip = ip 75 | self.media_path = media_path 76 | -------------------------------------------------------------------------------- /scripts/web_server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import logging 4 | 5 | from videowall.util import validate_positive_int_argument, validate_positive_float_argument, ip_from_ifname, get_ifnames 6 | from videowall.web_server import WebServer 7 | 8 | if __name__ == "__main__": 9 | 10 | parser = argparse.ArgumentParser(description="Videowall web server", 11 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 12 | parser.add_argument('ifname', choices=get_ifnames()) 13 | parser.add_argument('--media_path', help='Local path where the media files are located', default='~/Videos') 14 | parser.add_argument('--base_time_offset', help='Time between play request and the video playback. This gives ' 15 | 'the clients some time to load the video', 16 | type=validate_positive_float_argument, default=0.1) 17 | parser.add_argument('--server_web_port', help="Websocket port of the webserver", 18 | type=validate_positive_int_argument, default=3000) 19 | parser.add_argument('--server_broadcast_port', help="Port for broadcasting the server messages", 20 | type=validate_positive_int_argument, default=2000) 21 | parser.add_argument('--server_play_broadcast_port', help="Port for broadcasting server play messages", 22 | type=validate_positive_int_argument, default=2001) 23 | parser.add_argument('--server_clock_port', help="Gstreamer Netclock port", type=validate_positive_int_argument, 24 | default=11111) 25 | parser.add_argument('--server_broadcast_interval', help="How often the server sends a broadcast message", 26 | type=validate_positive_float_argument, default=1) 27 | parser.add_argument('--client_broadcast_port', help="Port for receiving client broadcast messages", 28 | type=validate_positive_int_argument, default=3000) 29 | parser.add_argument('--client_time_overlay', help="Whether the clients should add a time overlay to the video", 30 | action='store_true') 31 | 32 | parser.add_argument("-v", "--verbose", help="Verbose logging", action='store_true') 33 | args = parser.parse_args() 34 | 35 | if args.verbose: 36 | logging.getLogger().setLevel(logging.DEBUG) 37 | 38 | web_server = WebServer(args.server_web_port, args.media_path, args.base_time_offset, ip_from_ifname(args.ifname), 39 | args.server_broadcast_port, args.server_play_broadcast_port, args.server_clock_port, 40 | args.server_broadcast_interval, args.client_broadcast_port) 41 | web_server.run() 42 | -------------------------------------------------------------------------------- /src/videowall/networking/networking_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import socket 4 | 5 | from .message_definition import ServerPlayBroadcastMessage, ClientBroadcastMessage, ServerBroadcastMessage 6 | from .networking_exceptions import NetworkingException 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class NetworkingServer(object): 12 | def __init__(self, server_broadcast_port, server_play_broadcast_port, client_broadcast_port, buffer_size=1024): 13 | self._server_broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 14 | self._server_broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 15 | self._server_broadcast_port = server_broadcast_port 16 | self._server_play_broadcast_port = server_play_broadcast_port 17 | 18 | self._client_broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 19 | self._client_broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 20 | self._client_broadcast_socket.bind(("", client_broadcast_port)) # Bind to all 21 | self._client_broadcast_socket.settimeout(1e-4) 22 | 23 | self._server_play_broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 24 | self._server_play_broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 25 | 26 | self._buffer_size = buffer_size 27 | 28 | def send_play_broadcast(self, msg): 29 | if not isinstance(msg, ServerPlayBroadcastMessage): 30 | raise NetworkingException("msg ({}) is not of type ServerPlayBroadcastMessage".format(msg)) 31 | 32 | logger.debug("Sending %s", msg) 33 | self._server_broadcast_socket.sendto(json.dumps(msg.to_dict()).encode('utf-8'), 34 | ('', self._server_play_broadcast_port)) 35 | 36 | def send_broadcast(self, msg): 37 | if not isinstance(msg, ServerBroadcastMessage): 38 | raise NetworkingException("msg ({}) is not of type ServerBroadcastMessage".format(msg)) 39 | 40 | logger.debug("Sending %s", msg) 41 | self._server_broadcast_socket.sendto(json.dumps(msg.to_dict()).encode('utf-8'), 42 | ('', self._server_broadcast_port)) 43 | 44 | def receive_client_broadcast(self): 45 | data, _ = self._client_broadcast_socket.recvfrom(self._buffer_size) 46 | 47 | try: 48 | msg = ClientBroadcastMessage(**json.loads(data.decode("utf-8"))) 49 | except Exception as e: 50 | raise NetworkingException(e) 51 | else: 52 | logger.debug("Client broadcast received: %s", msg) 53 | return msg 54 | -------------------------------------------------------------------------------- /src/videowall/player/player_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | 5 | from pymediainfo import MediaInfo 6 | from videowall.gi_version import Gst, GstNet 7 | from videowall.util import validate_ip_port 8 | 9 | from .player_exceptions import PlayerException 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class PlayerServer(object): 15 | def __init__(self, ip, port): 16 | validate_ip_port(ip, port) 17 | 18 | self._ip = ip 19 | self._port = port 20 | self._base_time_nsecs = None 21 | self._duration = 0 22 | self._start = 0 23 | 24 | self._clock = Gst.SystemClock.obtain() 25 | self._clock_provider = GstNet.NetTimeProvider.new(self._clock, None, self._port) 26 | 27 | logger.debug("__init__(ip=%s, port=%s) constructed", ip, port) 28 | 29 | @staticmethod 30 | def _get_video_duration_from_file(filename): 31 | if not os.path.exists(filename): 32 | raise PlayerException("{} does not exist!".format(filename)) 33 | 34 | info = MediaInfo.parse(filename) 35 | 36 | for track in info.tracks: 37 | if track.track_type == 'Video': 38 | video_track = track 39 | break 40 | else: 41 | raise PlayerException("Video does not contain a video track") 42 | 43 | return round(video_track.duration / 1e3, 2) 44 | 45 | def play(self, filename, base_time_offset): 46 | self._start = time.time() + base_time_offset 47 | self._duration = self._get_video_duration_from_file(filename) 48 | self._base_time_nsecs = self._clock.get_time() + base_time_offset * 1e9 49 | 50 | logger.debug("play(start=%.2f, duration=%.2f, base_time_nsecs=%d, clock_time_nsecs=%d)", self._start, 51 | self._duration, self._base_time_nsecs, self._clock.get_time()) 52 | 53 | def get_ip(self): 54 | return self._ip 55 | 56 | def get_port(self): 57 | return self._port 58 | 59 | def get_base_time_nsecs(self): 60 | if self._base_time_nsecs is None: 61 | raise PlayerException('No base time available, please first play a file') 62 | return self._base_time_nsecs 63 | 64 | def get_position(self): 65 | return round(min(max(0.0, time.time() - self._start), self._duration), 2) 66 | 67 | def get_duration(self): 68 | return self._duration 69 | 70 | def stop(self): 71 | self._duration = None 72 | self._start = None 73 | self._base_time_nsecs = None 74 | logger.debug("stop") 75 | 76 | def is_playing(self): 77 | if self._duration is None: 78 | return False 79 | return self.get_position() < self._duration 80 | 81 | def close(self): 82 | self.stop() 83 | -------------------------------------------------------------------------------- /src/videowall/util.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import fcntl 3 | import os 4 | import re 5 | import socket 6 | import string 7 | import struct 8 | 9 | 10 | def to_dict(obj): 11 | """ 12 | Convert object to dictionary, source: 13 | 14 | https://stackoverflow.com/questions/1036409/recursively-convert-python-object-graph-to-dictionary 15 | 16 | :param obj: Instance of a class 17 | :return: Object serialized to a dictionary 18 | """ 19 | if isinstance(obj, dict): 20 | data = {} 21 | for (k, v) in obj.items(): 22 | data[k] = to_dict(v) 23 | return data 24 | elif hasattr(obj, "_ast"): 25 | return to_dict(obj._ast()) 26 | elif hasattr(obj, "__iter__") and not isinstance(obj, str): 27 | return [to_dict(v) for v in obj] 28 | elif hasattr(obj, "__dict__"): 29 | data = dict([(key, to_dict(value)) 30 | for key, value in obj.__dict__.items() 31 | if not callable(value) and not key.startswith('_')]) 32 | return data 33 | else: 34 | return obj 35 | 36 | 37 | def validate_ip(ip): 38 | try: 39 | socket.inet_pton(socket.AF_INET, ip) 40 | except socket.error as e: 41 | raise argparse.ArgumentTypeError(e) 42 | return ip 43 | 44 | 45 | def validate_positive_int_argument(value): 46 | ivalue = int(value) 47 | if ivalue <= 0: 48 | raise argparse.ArgumentTypeError("%s is an invalid positive int value" % value) 49 | return ivalue 50 | 51 | 52 | def validate_positive_float_argument(value): 53 | fvalue = float(value) 54 | if fvalue <= 0: 55 | raise argparse.ArgumentTypeError("%s is an invalid positive float value" % value) 56 | return fvalue 57 | 58 | 59 | def validate_ip_port(ip, port): 60 | validate_ip(ip) 61 | validate_positive_int_argument(port) 62 | return ip, port 63 | 64 | 65 | def validate_positive_or_zero_int_argument(value): 66 | ivalue = int(value) 67 | if ivalue < 0: 68 | raise argparse.ArgumentTypeError("%s is an invalid positive or zero int value" % value) 69 | return ivalue 70 | 71 | 72 | def get_ifnames(): 73 | return os.listdir('/sys/class/net/') 74 | 75 | 76 | def ip_from_ifname(ifname): 77 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 78 | return socket.inet_ntoa(fcntl.ioctl( 79 | s.fileno(), 80 | 0x8915, # SIOCGIFADDR 81 | struct.pack('256s', ifname[:15]) 82 | )[20:24]) 83 | 84 | 85 | def get_unique_filename(path): 86 | valid_chars = "-_/.%s%s" % (string.ascii_letters, string.digits) 87 | path = ''.join(c for c in path if c in valid_chars).replace(' ', '_') 88 | 89 | i = 0 90 | while os.path.exists(path): 91 | name, ext = os.path.splitext(path) 92 | s = re.search('(.*)_(\d+)\.', name) 93 | if s: 94 | name = s.group(1) 95 | i = int(s.group(2)) + 1 96 | path = "{}_{}{}".format(name, i, ext) 97 | return path 98 | -------------------------------------------------------------------------------- /src/videowall/networking/networking_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import socket 4 | 5 | from videowall.util import validate_ip_port, validate_positive_int_argument 6 | 7 | from .message_definition import ServerPlayBroadcastMessage, ServerBroadcastMessage 8 | from .networking_exceptions import NetworkingException 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class NetworkingClient(object): 14 | def __init__(self, ip, server_broadcast_port, server_play_broadcast_port, client_broadcast_port, buffer_size=1024): 15 | validate_ip_port(ip, server_broadcast_port) 16 | validate_positive_int_argument(client_broadcast_port) 17 | self._ip = ip 18 | 19 | self._server_broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 20 | self._server_broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 21 | self._server_broadcast_socket.bind(("", server_broadcast_port)) # Bind to all 22 | self._server_broadcast_socket.settimeout(5.0) 23 | self._server_play_broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 24 | self._server_play_broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 25 | self._server_play_broadcast_socket.bind(("", server_play_broadcast_port)) # Bind to all 26 | self._server_play_broadcast_socket.settimeout(5.0) 27 | self._buffer_size = buffer_size 28 | 29 | self._client_broadcast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 30 | self._client_broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 31 | self._client_broadcast_port = client_broadcast_port 32 | 33 | def send_client_broadcast(self, msg): 34 | logger.debug("Broadcasting client message: %s", msg) 35 | self._client_broadcast_socket.sendto(json.dumps(msg.to_dict()).encode('utf-8'), 36 | ('', self._client_broadcast_port)) 37 | 38 | def receive_server_broadcast(self): 39 | logger.debug("waiting for server broadcast message ...") 40 | 41 | # May raise a socket.timeout exception 42 | data, _ = self._server_broadcast_socket.recvfrom(self._buffer_size) 43 | 44 | try: 45 | msg = ServerBroadcastMessage(**json.loads(data.decode("utf-8"))) 46 | except Exception as e: 47 | raise NetworkingException(e) 48 | else: 49 | logger.debug("Server broadcast received: %s", msg) 50 | return msg 51 | 52 | def receive_server_play_broadcast(self): 53 | logger.debug("waiting for server play broadcast message ...") 54 | 55 | # May raise a socket.timeout exception 56 | data, _ = self._server_play_broadcast_socket.recvfrom(self._buffer_size) 57 | 58 | print(data) 59 | 60 | try: 61 | msg = ServerPlayBroadcastMessage(**json.loads(data.decode("utf-8"))) 62 | except Exception as e: 63 | raise NetworkingException(e) 64 | else: 65 | logger.debug("Server play broadcast received: %s", msg) 66 | return msg 67 | 68 | def get_ip(self): 69 | return self._ip 70 | 71 | def close(self): 72 | logger.debug("Closing NetworkingClient ...") 73 | -------------------------------------------------------------------------------- /src/videowall/client.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import logging 3 | import socket 4 | import threading 5 | import time 6 | 7 | from videowall.player.player_exceptions import PlayerException 8 | 9 | from .media_manager import MediaManagerClient 10 | from .networking import NetworkingClient 11 | from .networking.message_definition import ClientConfig, ClientBroadcastMessage 12 | from .player import PlayerClient 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class Client(object): 18 | def __init__(self, player_platform, media_path, ip, server_broadcast_port, server_play_broadcast_port, 19 | client_broadcast_port, client_broadcast_interval): 20 | self._networking = NetworkingClient(ip, server_broadcast_port, server_play_broadcast_port, 21 | client_broadcast_port) 22 | self._player_platform = player_platform 23 | self._player = None 24 | self._media_manager = MediaManagerClient(media_path) 25 | 26 | self._close = False 27 | 28 | self._client_broadcast_interval = client_broadcast_interval 29 | self._client_broadcast_thread = threading.Thread(target=self.send_client_broadcast) 30 | self._client_broadcast_thread.start() 31 | 32 | def send_client_broadcast(self): 33 | while not self._close: 34 | msg = ClientBroadcastMessage( 35 | getpass.getuser(), 36 | self._networking.get_ip(), 37 | self._media_manager.get_media_path() 38 | ) 39 | logger.debug("Broadcasting client message: %s", msg) 40 | self._networking.send_client_broadcast(msg) 41 | time.sleep(self._client_broadcast_interval) 42 | 43 | @staticmethod 44 | def _get_client_specific_config(ip, client_config): 45 | if ip in client_config: 46 | return client_config[ip] 47 | logging.warn("%s not present in client_config %s, using default config", ip, client_config) 48 | return ClientConfig.get_default() 49 | 50 | def run(self): 51 | while not self._close: 52 | try: 53 | logger.info("Waiting for server broadcast message ...") 54 | msg = self._networking.receive_server_broadcast() 55 | except socket.timeout: 56 | pass 57 | else: 58 | self._player = PlayerClient(self._player_platform, msg.clock_ip, msg.clock_port) 59 | break 60 | 61 | while not self._close: 62 | try: 63 | msg = self._networking.receive_server_play_broadcast() 64 | except socket.timeout: 65 | pass 66 | else: 67 | logger.info("Received play request: %s", msg) 68 | client_cfg = Client._get_client_specific_config(self._networking.get_ip(), msg.client_config) 69 | try: 70 | self._player.play(self._media_manager.get_full_path(msg.filename), msg.base_time_nsecs, 71 | client_cfg.videocrop_config, self._networking.get_ip(), msg.time_overlay) 72 | except PlayerException as e: 73 | logger.error(e) 74 | 75 | def close(self): 76 | self._networking.close() 77 | self._player.close() 78 | 79 | logger.debug("Closing Client ...") 80 | self._close = True 81 | self._client_broadcast_thread.join() 82 | -------------------------------------------------------------------------------- /web/src/components/PlaylistModal.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 85 | 86 | 127 | -------------------------------------------------------------------------------- /src/videowall/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | import time 4 | from collections import namedtuple 5 | 6 | import tornado 7 | 8 | from .media_manager import MediaManagerServer 9 | from .networking import NetworkingServer 10 | from .networking.message_definition import ServerPlayBroadcastMessage, ServerBroadcastMessage 11 | from .player import PlayerServer 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | RemoteClient = namedtuple('RemoteClient', 'username ip media_path age') 16 | 17 | 18 | class Server(object): 19 | def __init__(self, media_path, base_time_offset, ip, server_broadcast_port, server_play_broadcast_port, 20 | server_clock_port, server_broadcast_interval, client_broadcast_port): 21 | self._networking = NetworkingServer(server_broadcast_port, server_play_broadcast_port, client_broadcast_port) 22 | self._player = PlayerServer(ip, server_clock_port) 23 | self._base_time_offset = base_time_offset 24 | self._media_manager = MediaManagerServer(media_path) 25 | self._media_filename = None 26 | 27 | self._server_broadcast_timer = tornado.ioloop.PeriodicCallback(self._server_broadcast, 28 | server_broadcast_interval * 1e3) 29 | self._server_broadcast_timer.start() 30 | 31 | self._receive_client_broadcast_timer = tornado.ioloop.PeriodicCallback(self._receive_client_broadcast, 10) 32 | self._receive_client_broadcast_timer.start() 33 | 34 | self._check_done_timer = tornado.ioloop.PeriodicCallback(self._check_player_done, 100) 35 | self._check_done_timer.start() 36 | 37 | self._clients = {} 38 | self._client_config = {} 39 | 40 | def _server_broadcast(self): 41 | self._networking.send_broadcast(ServerBroadcastMessage( 42 | clock_ip=self._player.get_ip(), 43 | clock_port=self._player.get_port() 44 | )) 45 | 46 | def _receive_client_broadcast(self): 47 | try: 48 | msg = self._networking.receive_client_broadcast() 49 | except socket.timeout: 50 | pass 51 | except Exception as e: 52 | logger.error(e) 53 | else: 54 | self._clients[msg.ip] = { 55 | "time": time.time(), 56 | "msg": msg 57 | } 58 | 59 | def _check_player_done(self): 60 | if self._media_filename and not self._player.is_playing(): 61 | self.play(self._media_filename) 62 | 63 | def get_media_filenames(self): 64 | return self._media_manager.get_filenames() 65 | 66 | def play(self, filename): 67 | self._media_filename = filename 68 | self._player.play(self._media_manager.get_full_path(filename), self._base_time_offset) 69 | self._networking.send_play_broadcast(ServerPlayBroadcastMessage( 70 | filename=filename, 71 | base_time_nsecs=self._player.get_base_time_nsecs(), 72 | time_overlay=True, 73 | client_config=self._client_config 74 | )) 75 | 76 | def set_client_config(self, config): 77 | self._client_config = config 78 | 79 | def get_client_config(self): 80 | return self._client_config 81 | 82 | def is_playing(self): 83 | return self._player.is_playing() 84 | 85 | def delete_media(self, filename): 86 | self._media_manager.delete_media(filename) 87 | 88 | def close(self): 89 | self._player.close() 90 | 91 | def get_duration(self): 92 | return self._player.get_duration() 93 | 94 | def get_position(self): 95 | return self._player.get_position() 96 | 97 | def get_clients(self): 98 | now = time.time() 99 | return [{ 100 | "username": c["msg"].username, 101 | "ip": c["msg"].ip, 102 | "media_path": c["msg"].media_path, 103 | "age": now - c["time"] 104 | } for c in self._clients.values()] 105 | 106 | def sync_media(self): 107 | logger.info("Syncing media ..") 108 | self._media_manager.sync( 109 | ["{}@{}:{}".format(c["username"], c["ip"], c["media_path"]) for c in self.get_clients()]) 110 | logger.info("Done syncing media") 111 | 112 | def get_media_path(self): 113 | return self._media_manager.get_media_path() 114 | 115 | def get_current_media_filename(self): 116 | return self._media_filename 117 | 118 | def get_state_dict(self): 119 | return { 120 | "player": { 121 | "media_path": self.get_media_path(), 122 | "media_filenames": self.get_media_filenames(), 123 | "current_media_filename": self.get_current_media_filename(), 124 | "is_playing": self.is_playing(), 125 | "duration": self.get_duration(), 126 | "position": self.get_position() 127 | }, 128 | "client_config": self.get_client_config(), 129 | "clients": self.get_clients() 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Videowall 2 | 3 | [![Build Status](https://travis-ci.org/reinzor/videowall.svg?branch=master)](https://travis-ci.org/reinzor/videowall) 4 | 5 | ![2 monitor example](doc/example_2monitor.gif) 6 | 7 | Video wall with multiple tiles that enables synchronized video playback, mirrored or tiled. 8 | 9 | ## Demo videos 10 | 11 | | Description | Video | 12 | :-------------------------:|:-------------------------: 13 | 6x RPI Zero - 720p - Big bug bunny | [![](https://img.youtube.com/vi/f5Dp35RL9q8/mqdefault.jpg)](https://www.youtube.com/watch?v=f5Dp35RL9q8&t=6s) 14 | 2x RPI Zero - 720p - Big bug bunny | [![](https://img.youtube.com/vi/J6anLNTHhKU/mqdefault.jpg)](https://www.youtube.com/watch?v=J6anLNTHhKU&t=6s) 15 | 2x RPI Zero - 720p - Simpsons | [![](https://img.youtube.com/vi/LbjiZv7XG90/mqdefault.jpg)](https://www.youtube.com/watch?v=LbjiZv7XG90) 16 | 4x RPI Zero + laptop - 720p - Fantastic 4 | [![](https://img.youtube.com/vi/6yAyf_zFOXs/mqdefault.jpg)](https://www.youtube.com/watch?v=6yAyf_zFOXs) 17 | 18 | ## Installation 19 | 20 | ### Software 21 | 22 | #### Raspberry PI 23 | 24 | ##### Installation prerequisites 25 | 26 | - Raspbian Stretch Lite 27 | - Raspberry Pi 3 / Raspberry Pi Zero (other Pi's not tested) 28 | - Videowall repository is your current working directory 29 | 30 | ##### Installation from source 31 | 32 | - Install Raspbian Stretch lite on an sd card of at least 4GB 33 | - Place an `ssh` document in the `/boot` partition to enable direct ssh access 34 | - Login to the raspberry pi and start the installation: 35 | 36 | ``` 37 | sudo apt-get -y update && \ 38 | sudo apt-get -y install git && \ 39 | git clone https://github.com/reinzor/videowall.git && cd videowall && \ 40 | ./install_raspberry_pi_stretch_lite_autostart.bash 41 | ``` 42 | 43 | This installs the videowall software and enables a client service on startup. 44 | 45 | #### Ubuntu x86 46 | 47 | Installation / usage instruction video: https://youtu.be/XLXoX8bj2vA 48 | 49 | ##### Installation prerequisites 50 | 51 | - Ubuntu x86 16.04 LTS (other versions not tested) 52 | - Videowall repository is your current working directory 53 | 54 | ##### Installation 55 | 56 | ``` 57 | ./install_ubuntu_x86.bash 58 | ``` 59 | 60 | ### Hardware 61 | 62 | #### Raspberry PI 63 | 64 | ##### Components per client without cables 65 | 66 | - [Raspberry PI zero](https://www.adafruit.com/product/2885) 67 | - [Micro USB 2.0 naar Ethernet 10/100 RJ45 Network Lan Adapter Card](https://nl.banggood.com/Micro-USB-2_0-to-Ethernet-10-or-100-RJ45-Network-Lan-Adapter-Card-p-921585.html) 68 | - 4GB+ Micro SD card 69 | 70 | Cost `~15 USD` per client 71 | 72 | ### Quick start 73 | 74 | Make sure you have sources the `setup.bash` from the root directory. This will set the Gstreamer plugin paths and appends the videowall library to your `PYTHONPATH`. 75 | 76 | #### Server 77 | 78 | scripts/web_server 79 | 80 | #### Client 81 | 82 | scripts/client 83 | 84 | This is automatically started on a raspberry pi after installation. Can be manually started on an ubuntu x86 environment. 85 | 86 | ### How to create release image 87 | 88 | - Create a git tag and update release notes 89 | - Ensure new MAC generation on startup 90 | 91 | ```bash 92 | sudo rm -f /etc/network/mac 93 | ``` 94 | 95 | - Create `.img` file on Ubuntu host computer (insert sd card): 96 | 97 | ```bash 98 | sudo fdisk -l # Get the disk name of the sd card 99 | sudo dd bs=4M if=/path/to/disk of=/path/to/image.img 100 | ``` 101 | 102 | - Shrink the image using [pishrink.sh](https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh) 103 | 104 | ```bash 105 | sudo ./pishrink.sh /path/to/image.img /path/to/shrinked_image.img 106 | ``` 107 | 108 | - Create a `tar.gz` from image file: 109 | 110 | ``` 111 | Right click, compress, tar.gz 112 | ``` 113 | 114 | ## References 115 | 116 | - [Cinder GST Sync Player](https://github.com/patrickFuerst/Cinder-GstVideoSyncPlayer) 117 | - [Override getty1](https://raymii.org/s/tutorials/Run_software_on_tty1_console_instead_of_login_getty.html) 118 | - [Vigsterkr pi-wall](https://github.com/vigsterkr/pi-wall) 119 | - [Gstreamer mmal for smooth video playback on RPI](https://gstreamer.freedesktop.org/data/events/gstreamer-conference/2016/John%20Sadler%20-%20Smooth%20video%20on%20Raspberry%20Pi%20with%20gst-mmal%20(Lightning%20Talk).pdf) 120 | - [Gstreamer sync server for synchronized playback on multiple client with a gstreamer client server set-up](https://github.com/ford-prefect/gst-sync-server) 121 | - [Multicast Video-Streaming on Embedded Linux Environment, Daichi Fukui, Toshiba Corporation, Japan Technical Jamboree 63, Dec 1st, 2017](https://elinux.org/images/3/33/Multicast_jamboree63_fukui.pdf) 122 | - [omxplayer-sync](https://github.com/turingmachine/omxplayer-sync) 123 | - [pwomxplayer](https://github.com/JeffCost/pwomxplayer) 124 | - [dbus vlc](https://wiki.videolan.org/DBus-spec/) 125 | - [dbus tutorial phython for MPRIS](http://amhndu.github.io/Blog/python-dbus-mpris.html) 126 | - [dbus omxplayer](https://github.com/popcornmix/omxplayer) 127 | - [GPU memory 90 degrees omxplayer](https://github.com/popcornmix/omxplayer/issues/467) 128 | - [Remote dbus control](https://stackoverflow.com/questions/10158684/connecting-to-dbus-over-tcp/13275973#13275973) 129 | - [GST OMX](https://github.com/GStreamer/gst-omx) 130 | - [GST MMAL](https://github.com/youviewtv/gst-mmal) 131 | - [Disable Boot text](https://retropie.org.uk/docs/FAQ/#how-do-i-hide-the-boot-text) 132 | -------------------------------------------------------------------------------- /src/videowall/web_server.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import logging 4 | import os.path 5 | import shutil 6 | from tempfile import NamedTemporaryFile 7 | 8 | import tornado.escape 9 | import tornado.ioloop 10 | import tornado.options 11 | import tornado.web 12 | import tornado.websocket 13 | from pymediainfo import MediaInfo 14 | from videowall.server import Server 15 | from videowall.util import get_unique_filename 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class WebServer(tornado.web.Application): 21 | def __init__(self, server_web_port, media_path, base_time_offset, ip, server_broadcast_port, 22 | server_play_broadcast_port, server_clock_port, 23 | server_broadcast_interval, client_broadcast_port): 24 | self._server = Server(media_path, base_time_offset, ip, server_broadcast_port, server_play_broadcast_port, 25 | server_clock_port, server_broadcast_interval, client_broadcast_port) 26 | 27 | self._server_web_port = server_web_port 28 | 29 | UploadHandler.path = self._server.get_media_path() 30 | WebSocketHandler.server = self._server 31 | super(WebServer, self).__init__([ 32 | ("/upload", UploadHandler), 33 | ("/ws", WebSocketHandler), 34 | ("/(.*)", tornado.web.StaticFileHandler, { 35 | "path": os.path.join(os.path.dirname(__file__), "../../web/dist/"), 36 | "default_filename": "index.html" 37 | }), 38 | ]) 39 | self.listen(self._server_web_port) 40 | 41 | def run(self): 42 | tornado.ioloop.IOLoop.current().start() 43 | 44 | 45 | class WebSocketHandler(tornado.websocket.WebSocketHandler): 46 | def __init__(self, application, request, **kwargs): 47 | super(WebSocketHandler, self).__init__(application, request, **kwargs) 48 | self._broadcast_timer = tornado.ioloop.PeriodicCallback(self._broadcast, 100) 49 | 50 | self._command_handlers = { 51 | "play": self._play, 52 | "delete": self._delete, 53 | "set_client_config": self._set_client_config, 54 | "sync_media": self._sync_media 55 | } 56 | 57 | def check_origin(self, origin): 58 | """ 59 | Allow external connections, e.g. dev server 60 | """ 61 | return True 62 | 63 | def open(self): 64 | logger.info("New websocket connection!") 65 | self._broadcast_timer.start() 66 | 67 | def _broadcast(self): 68 | self.write_message(WebSocketHandler.server.get_state_dict()) 69 | 70 | def on_message(self, message): 71 | try: 72 | message = json.loads(message) 73 | except Exception as e: 74 | logger.error("Could not decode command: {}".format(e)) 75 | 76 | if "command" not in message or "arguments" not in message: 77 | logger.error("Message should contain a command and arguments key!") 78 | return 79 | 80 | cmd = message["command"] 81 | args = message["arguments"] 82 | 83 | logger.info("Command %s with arguments %s", cmd, args) 84 | 85 | if cmd in self._command_handlers: 86 | f = self._command_handlers[cmd] 87 | try: 88 | f(**args) 89 | except TypeError: 90 | logger.error( 91 | "Invalid args for cmd {}. Received {} expected {}".format(cmd, args.keys(), inspect.getargspec(f))) 92 | else: 93 | logger.warning("No registered handler for command {}".format(message["command"])) 94 | 95 | def _delete(self, filename): 96 | WebSocketHandler.server.delete_media(filename) 97 | 98 | def _play(self, filename): 99 | try: 100 | WebSocketHandler.server.play(filename) 101 | except Exception as e: 102 | logger.error(e) 103 | 104 | def _set_client_config(self, config): 105 | WebSocketHandler.server.set_client_config(config) 106 | 107 | def _sync_media(self): 108 | WebSocketHandler.server.sync_media() 109 | 110 | def on_close(self): 111 | self._broadcast_timer.stop() 112 | 113 | 114 | class UploadHandler(tornado.web.RequestHandler): 115 | @staticmethod 116 | def _validate_720p_mp4_file(filename): 117 | info = MediaInfo.parse(filename) 118 | 119 | for track in info.tracks: 120 | if track.track_type == 'Video': 121 | video_track = track 122 | if video_track.codec != 'AVC': 123 | raise Exception("Video should have an AVC (h264) encoding") 124 | if video_track.width != 1280 or video_track.height != 720: 125 | raise Exception( 126 | "Video should be of size 1280x720, it is {}x{}".format(video_track.width, video_track.height)) 127 | break 128 | else: 129 | raise Exception("File does not contain a video track") 130 | 131 | def post(self): 132 | file_info = self.request.files['file'][0] 133 | logger.info("Received upload: (content_type=%s, filename=%s)", file_info['content_type'], file_info['filename']) 134 | filename = get_unique_filename(os.path.join(UploadHandler.path, file_info['filename'])) 135 | file_handle = NamedTemporaryFile(delete=False) 136 | file_handle.write(file_info['body']) 137 | file_handle.close() 138 | try: 139 | self._validate_720p_mp4_file(file_handle.name) 140 | except Exception as e: 141 | logger.error(e) 142 | self.set_status(400) 143 | self.finish({"reason": str(e)}) 144 | else: 145 | logger.info("Writing to %s", filename) 146 | shutil.copyfile(file_handle.name, filename) 147 | logger.info("Wrote to %s", filename) 148 | self.finish("Uploaded {}".format(filename)) 149 | -------------------------------------------------------------------------------- /src/videowall/player/player_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import os.path 5 | import threading 6 | import time 7 | 8 | from videowall.gi_version import GstNet, Gst, GObject 9 | from videowall.util import validate_ip_port 10 | 11 | from .player_exceptions import PlayerException 12 | from .player_platforms import PlayerPlatform, PlayerPlatformPC, PlayerPlatformRaspberryPi, get_player_platforms 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | ONE_MINUTE_SECONDS = 60 17 | 18 | 19 | class PlayerClient(object): 20 | Gst.init(None) 21 | 22 | def __init__(self, platform, clock_ip, clock_port, use_local_clock=False): 23 | if not issubclass(platform, PlayerPlatform): 24 | raise PlayerException("Invalid player platform {}, available platforms: {}".format(platform, 25 | get_player_platforms())) 26 | self._use_local_clock = use_local_clock 27 | 28 | if not self._use_local_clock: 29 | validate_ip_port(clock_ip, clock_port) 30 | clock_name = "clock0" 31 | try: 32 | self._clock = GstNet.NetClientClock.new(clock_name, clock_ip, clock_port, 0) 33 | except TypeError as e: 34 | raise PlayerException("GstNet.NetClientClock.new({}, {}, {}) failed ({}). Set environment variable " 35 | "GST_DEBUG=1 for more info".format(clock_name, clock_ip, clock_port, e)) 36 | else: 37 | self._clock = Gst.SystemClock.obtain() 38 | 39 | self._pipeline = None 40 | 41 | def run_player_thread(): 42 | GObject.MainLoop().run() 43 | 44 | self._gobject_thread = threading.Thread(target=run_player_thread) 45 | self._gobject_thread.start() 46 | 47 | self._platform = platform 48 | 49 | logger.info("Player constructed (platform=%s, clock_ip=%s, clock_port=%s, use_local_clock=%s)", platform, clock_ip, clock_port, use_local_clock) 50 | 51 | def _construct_pipeline(self, filename, videocrop_config, text_overlay, time_overlay): 52 | real_path = os.path.realpath(os.path.expanduser(filename)) 53 | 54 | launch_cmd = "" 55 | if os.path.isfile(real_path): 56 | launch_cmd += "filesrc location={}".format(real_path) 57 | 58 | if self._platform == PlayerPlatformPC: 59 | launch_cmd += " ! decodebin" 60 | elif self._platform == PlayerPlatformRaspberryPi: 61 | launch_cmd += " ! qtdemux ! h264parse ! omxh264dec" 62 | 63 | launch_cmd += " ! videocrop bottom={} left={} right={} top={}".format(videocrop_config.bottom, 64 | videocrop_config.left, 65 | videocrop_config.right, 66 | videocrop_config.top) 67 | launch_cmd += " ! videoconvert" 68 | if text_overlay: 69 | launch_cmd += ' ! textoverlay text="{}" font-desc="Sans, 12"'.format(text_overlay) 70 | 71 | launch_cmd += ' ! timeoverlay font-desc="Sans, {}"'.format(12 if time_overlay else 1e-3) 72 | launch_cmd += " ! queue" 73 | 74 | logger.debug("Creating pipeline from launch command %s ..", launch_cmd) 75 | else: 76 | error_image_location = os.path.join(os.path.dirname(os.path.realpath(__file__)), "assets", "error.jpg") 77 | launch_cmd += "filesrc location={}".format(error_image_location) 78 | launch_cmd += " ! jpegparse ! jpegdec ! imagefreeze" 79 | if text_overlay: 80 | launch_cmd += ' ! textoverlay text="{}\n{}"'.format("%s not found" % filename, text_overlay) 81 | launch_cmd += " ! videoconvert" 82 | 83 | if self._platform == PlayerPlatformPC: 84 | launch_cmd += " ! ximagesink" # or ! fakesink sync=true # sync required for realtime playback 85 | else: 86 | launch_cmd += " ! mmalvideosink" 87 | 88 | logger.debug("gst-launch-1.0 -v %s", launch_cmd) 89 | self._pipeline = Gst.parse_launch(launch_cmd) 90 | 91 | def _destroy_pipeline(self): 92 | if self._pipeline is not None: 93 | self._pipeline.set_state(Gst.State.NULL) 94 | self._pipeline = None 95 | 96 | def close(self): 97 | self.stop() 98 | GObject.MainLoop().quit() 99 | logger.info("Waiting for the GObject Thread to join ..") 100 | self._gobject_thread.join() 101 | 102 | def play(self, filename, base_time_nsecs, videocrop_config, text_overlay, time_overlay): 103 | if not self._use_local_clock: 104 | # Make sure the client clock is in sync with the server 105 | sync_grace = time.time() + 5.0 106 | delta = (base_time_nsecs - self._clock.get_time()) / 1e9 107 | while delta > ONE_MINUTE_SECONDS or delta < 0: 108 | delta = (base_time_nsecs - self._clock.get_time()) / 1e9 109 | time.sleep(0.1) 110 | if time.time() > sync_grace: 111 | msg = 'Delta between clock and base_time negative or too large: {} > ONE_MINUTE_SECONDS ({})' \ 112 | .format(delta, ONE_MINUTE_SECONDS) 113 | raise PlayerException(msg) 114 | else: 115 | base_time_nsecs = self._clock.get_time() 116 | 117 | if self._pipeline: 118 | self._destroy_pipeline() 119 | self._construct_pipeline(filename, videocrop_config, text_overlay, time_overlay) 120 | 121 | self._pipeline.set_start_time(Gst.CLOCK_TIME_NONE) 122 | self._pipeline.use_clock(self._clock) 123 | self._pipeline.set_base_time(base_time_nsecs) 124 | 125 | self._pipeline.set_state(Gst.State.PLAYING) 126 | 127 | # Make sure we have transitioned to the PLAYING state 128 | play_grace = time.time() + 5.0 129 | ret, state, pending = self._pipeline.get_state(timeout=Gst.SECOND) 130 | while state != Gst.State.PLAYING: 131 | ret, state, pending = self._pipeline.get_state(timeout=Gst.SECOND) 132 | time.sleep(0.1) 133 | if time.time() > play_grace: 134 | msg = 'Transition to playing state took too long!' 135 | raise PlayerException(msg) 136 | 137 | logger.info('play(base_time_nsecs=%d, clock_time_nsecs=%d)', base_time_nsecs, self._clock.get_time()) 138 | 139 | def stop(self): 140 | self._destroy_pipeline() 141 | 142 | def is_playing(self): 143 | ret, state, pending = self._pipeline.get_state(timeout=Gst.SECOND) 144 | return state == Gst.State.PLAYING 145 | 146 | def get_duration(self): 147 | _, duration = self._pipeline.query_duration(Gst.Format.TIME) 148 | return round(duration / 1e9, 2) 149 | 150 | def get_position(self): 151 | _, position = self._pipeline.query_position(Gst.Format.TIME) 152 | return round(position / 1e9, 2) 153 | -------------------------------------------------------------------------------- /web/src/components/ScreenGrid.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 210 | 255 | -------------------------------------------------------------------------------- /web/dist/js/app.7e10ce6b.js: -------------------------------------------------------------------------------- 1 | (function(t){function e(e){for(var a,o,s=e[0],c=e[1],l=e[2],u=0,p=[];u9?e:"0"+e,i=n>9?n:"0"+n,o=a>9?a:"0"+a;return r+":"+i+":"+o});var l=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{attrs:{id:"app"}},[n("NavBar"),n("Player")],1)},d=[],u=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("b-navbar",{attrs:{toggleable:"md",type:"dark",variant:"info",id:"navbar"}},[n("b-container",[n("b-navbar-toggle",{attrs:{target:"nav_collapse"}}),n("b-navbar-brand",{attrs:{href:"#"}},[t._v("Video wall")]),n("b-collapse",{attrs:{"is-nav":"",id:"nav_collapse"}},[n("b-navbar-nav",{staticClass:"ml-auto"},[n("b-nav-item",{attrs:{to:"/player"}},[t._v("Player")])],1)],1)],1)],1)},p=[],f={name:"NavBar",components:{}},v=f,m=(n("34ba"),n("2877")),b=Object(m["a"])(v,u,p,!1,null,null,null);b.options.__file="NavBar.vue";var y=b.exports,h=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{attrs:{id:"videoWall"}},[n("ScreenGrid",{attrs:{clientConfig:t.serverState.client_config,clients:t.serverState.clients}}),n("center",[n("PlayerBar",{attrs:{playerState:t.serverState.player},on:{play:function(e){t.play(e)}}})],1)],1)},g=[],_=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{attrs:{id:"playerBar"}},[n("PlaylistModal",{attrs:{playerState:t.playerState},on:{play:function(e){t.$emit("play",e)}}}),n("b-button-group",[n("b-button",{attrs:{variant:"outline-secondary"}},[n("v-icon",{attrs:{name:"backward"}})],1),n("b-button",{attrs:{variant:"outline-secondary",disabled:!t.playerState.current_media_filename},on:{click:function(e){t.$emit("play",t.playerState.current_media_filename)}}},[n("v-icon",{attrs:{name:"sync-alt"}})],1),n("b-button",{attrs:{variant:"outline-secondary"}},[n("v-icon",{attrs:{name:"forward"}})],1)],1),n("div",{staticClass:"playerTime"},[n("b-form-input",{attrs:{type:"text",placeholder:t._f("hoursMinutesSeconds")(t.playerState.position),disabled:""}})],1),n("div",{attrs:{id:"playerProgress"}},[n("b-progress",{attrs:{max:t.playerState.duration,variant:"info"}},[n("b-progress-bar",{attrs:{value:t.playerState.position},domProps:{textContent:t._s(t.playerState.current_media_filename)}})],1)],1),n("div",{staticClass:"playerTime"},[n("b-form-input",{attrs:{type:"text",placeholder:t._f("hoursMinutesSeconds")(t.playerState.duration),disabled:""}})],1),n("b-button-group",[n("b-button",{directives:[{name:"b-modal",rawName:"v-b-modal.playlistModal",modifiers:{playlistModal:!0}}],attrs:{variant:"outline-secondary"}},[n("v-icon",{attrs:{name:"list-ul"}})],1)],1)],1)},S=[],x=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("b-modal",{attrs:{id:"playlistModal","hide-footer":"",title:"Playlist"}},[n("b-list-group",t._l(t.playerState.media_filenames,function(e){return n("b-list-group-item",{key:e,staticClass:"flex-column align-items-start",attrs:{variant:t.playerState.current_media_filename==e?"info":""}},[n("div",[n("div",{staticClass:"d-flex w-100 justify-content-between"},[n("h5",{staticClass:"mb-1",domProps:{textContent:t._s(e)}}),n("b-button-group",{staticClass:"playlistVideoButtons",attrs:{size:"sm"}},[n("b-button",{attrs:{variant:"outline-secondary"},on:{click:function(n){t.$emit("play",e)}}},[t.playerState.current_media_filename!=e?n("v-icon",{attrs:{name:"play"}}):n("v-icon",{attrs:{name:"sync-alt"}})],1),t.playerState.current_media_filename!=e?n("b-button",{attrs:{variant:"outline-secondary"},on:{click:function(n){t.deleteMedia(e)}}},[n("v-icon",{attrs:{name:"times"}})],1):t._e()],1)],1)])])})),t.err?n("b-alert",{attrs:{variant:"danger",show:"",dismissible:""},domProps:{textContent:t._s(t.err)}}):t._e(),n("vue2-dropzone",{ref:"dropzone",attrs:{id:"dropzone",options:t.dropzoneOptions},on:{"vdropzone-file-added":function(e){t.err=""},"vdropzone-complete":function(e){t.uploadComplete(t.$refs.dropzone)},"vdropzone-error":t.uploadError}})],1)},w=[],C=(n("b012"),n("1516")),k=n.n(C),P=n("92c3"),M=n.n(P),O=(n("1e3f"),{components:{draggable:k.a,vue2Dropzone:M.a},props:{playerState:{type:Object,required:!0}},data:function(){return{dropzoneOptions:{url:"/upload",thumbnailWidth:200,maxFilesize:500},err:""}},methods:{uploadComplete:function(t){t.removeAllFiles(),this.syncMedia()},uploadError:function(t){this.err=JSON.parse(t.xhr.response).reason},deleteMedia:function(t){this.$socket.sendObj({command:"delete",arguments:{filename:t}}),this.syncMedia()},syncMedia:function(){this.$socket.sendObj({command:"sync_media",arguments:{}})}}}),j=O,$=(n("e486"),Object(m["a"])(j,x,w,!1,null,null,null));$.options.__file="PlaylistModal.vue";var z=$.exports,E={name:"PlayerBar",components:{PlaylistModal:z},props:{playerState:{type:Object,required:!0}}},G=E,I=(n("9f4d"),Object(m["a"])(G,_,S,!1,null,null,null));I.options.__file="PlayerBar.vue";var A=I.exports,B=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("b-card",{staticClass:"mx-auto",style:{width:t.screenGrid.w+"px"},attrs:{id:"screenGrid"}},[n("div",{attrs:{slot:"header"},slot:"header"},[n("b-button-toolbar",{staticClass:"float-sm-right",attrs:{size:"sm"}},[n("b-button",{directives:[{name:"b-modal",rawName:"v-b-modal.clientsModal",modifiers:{clientsModal:!0}}],staticStyle:{"margin-right":"5px"},attrs:{variant:"outline-secondary"}},[n("v-icon",{attrs:{name:"users"}})],1),t.editState.active?n("b-button-group",{staticClass:"mx-1"},[n("b-button",{attrs:{variant:"outline-secondary"},on:{click:t.cancelEdit}},[n("v-icon",{attrs:{name:"times"}})],1),n("b-button",{attrs:{variant:"outline-secondary"},on:{click:t.applyEdit}},[n("v-icon",{attrs:{name:"check"}})],1)],1):n("b-button",{attrs:{variant:"outline-secondary"},on:{click:t.edit}},[n("v-icon",{attrs:{name:"edit"}})],1)],1)],1),n("div",{attrs:{id:"screenGridLayout"},on:{click:function(e){t.screenGridLayoutClicked(e)}}},[n("b-modal",{attrs:{id:"clientsModal","hide-footer":"",title:"Clients"}},[n("b-list-group",[n("b-button",{on:{click:t.syncMedia}},[t._v("Sync media")]),t._l(t.clients,function(e){return n("b-list-group-item",{key:e.ip,staticClass:"flex-column align-items-start",attrs:{variant:t.clientActive(e.ip)?"success":"danger"}},[n("div",[n("div",{staticClass:"d-flex justify-content-between align-items-center"},[n("table",{staticClass:"clientTable"},[n("tr",[n("td",[t._v("IP:")]),n("td",{domProps:{textContent:t._s(e.ip)}})]),n("tr",[n("td",[t._v("Username:")]),n("td",{domProps:{textContent:t._s(e.username)}})]),n("tr",[n("td",[t._v("Media path:")]),n("td",{domProps:{textContent:t._s(e.media_path)}})])]),n("b-badge",{attrs:{variant:"info"}},[t._v("Age: "+t._s(e.age))])],1)])])})],2)],1),n("b-modal",{ref:"addModal",attrs:{"hide-footer":"",title:"Add screen for client"}},[n("div",{staticClass:"d-block text-center"},[n("b-form",{on:{submit:function(e){e.preventDefault(),t.addScreen()}}},[n("b-form-select",{attrs:{options:t.clientIpOptions,required:""},model:{value:t.editState.addScreenIp,callback:function(e){t.$set(t.editState,"addScreenIp",e)},expression:"editState.addScreenIp"}}),n("b-button",{attrs:{type:"submit",variant:"primary"}},[t._v("Add screen")])],1)],1)]),n("grid-layout",{style:{height:t.screenGrid.h+"px",cursor:t.editState.active?"cell":"default"},attrs:{layout:t.screens,colNum:1280,rowHeight:1,maxRows:720,isDraggable:t.editState.active,isResizable:t.editState.active,isMirrored:!1,margin:[0,0],verticalCompact:!1,autoSize:!0,useCssTransforms:!0}},t._l(t.screens,function(e){return n("grid-item",{key:e.i,staticClass:"screen",style:{cursor:t.editState.active?"grabbing":"default"},attrs:{x:e.x,y:e.y,w:e.w,h:e.h,i:e.i}},[n("b-card",{attrs:{footer:e.x+","+e.y+" ["+e.w+"x"+e.h+"]"}},[n("div",{attrs:{slot:"header"},slot:"header"},[n("span",{domProps:{textContent:t._s("Screen "+e.i)}}),n("b-button-toolbar",{staticClass:"float-sm-right"},[n("b-button-group",{staticClass:"mx-1",attrs:{size:"sm"}},[n("b-button",{attrs:{variant:t.clientActive(e.i)?"success":"danger"}},[n("v-icon",{attrs:{name:"plug"}})],1),t.editState.active?n("b-button",{attrs:{variant:"danger"},on:{click:function(n){t.removeScreen(e.i)}}},[n("v-icon",{attrs:{name:"trash-alt"}})],1):t._e()],1)],1)],1)])],1)}))],1)])},N=[],T=(n("ac6a"),n("7be8")),q=n.n(T),J={name:"ScreenGrid",components:{VueGridLayout:q.a},props:{clientConfig:{type:Object,required:!0},clients:{type:Array,required:!0}},data:function(){return{editState:{active:!1,addScreenIp:"",clickedPosition:{x:0,y:0}},screenGrid:{w:1280,h:720},screens:[]}},watch:{clientConfig:function(t){if(!this.editState.active)for(var e in this.screens=[],t)this.screens.push({i:e,x:t[e].videocrop_config.left,y:t[e].videocrop_config.top,w:1280-t[e].videocrop_config.left-t[e].videocrop_config.right,h:720-t[e].videocrop_config.top-t[e].videocrop_config.bottom})}},computed:{clientIpOptions:function(){var t=this,e=[];return this.clients.forEach(function(n){n.ip in t.clientConfig||e.push(n.ip)}),e}},methods:{edit:function(){this.editState.active=!0},cancelEdit:function(){this.editState.active=!1},applyEdit:function(){var t={};this.screens.forEach(function(e){t[e.i]={videocrop_config:{bottom:720-e.y-e.h,left:e.x,right:1280-e.x-e.w,top:e.y}}}),this.$socket.sendObj({command:"set_client_config",arguments:{config:t}}),this.editState.active=!1},addScreen:function(){this.screens.push({x:this.editState.clickedPosition.x,y:this.editState.clickedPosition.y,w:200,h:200,i:this.editState.addScreenIp}),this.$refs.addModal.hide()},removeScreen:function(t){this.screens=this.screens.filter(function(e){return e.i!==t})},screenGridLayoutClicked:function(t){this.editState.active&&"vue-grid-layout"==t.target.getAttribute("class")&&(this.$refs.addModal.show(),this.editState.clickedPosition.x=t.offsetX,this.editState.clickedPosition.y=t.offsetY)},clientActive:function(t){var e=!1;return this.clients.forEach(function(n){n.ip==t&&n.age<5&&(e=!0)}),e},syncMedia:function(){this.$socket.sendObj({command:"sync_media",arguments:{}})}}},L=J,R=(n("0e07"),Object(m["a"])(L,B,N,!1,null,null,null));R.options.__file="ScreenGrid.vue";var D=R.exports,V={name:"Player",components:{PlayerBar:A,ScreenGrid:D},created:function(){var t=this;this.$options.sockets.onmessage=function(e){return t.messageReceived(e)},this.$options.sockets.onopen=function(e){return t.newConnection(e)}},data:function(){return{serverState:{client_config:{},clients:[],player:{}}}},methods:{newConnection:function(t){console.log(t)},messageReceived:function(t){this.serverState=JSON.parse(t.data)},play:function(t){this.$socket.sendObj({command:"play",arguments:{filename:t}})}}},F=V,W=(n("6131"),Object(m["a"])(F,h,g,!1,null,null,null));W.options.__file="Player.vue";var H=W.exports,U={name:"app",components:{NavBar:y,Player:H}},X=U,Y=(n("034f"),Object(m["a"])(X,l,d,!1,null,null,null));Y.options.__file="App.vue";var K=Y.exports;a["default"].use(r["a"]),a["default"].use(i["a"]),a["default"].component("v-icon",o["a"]),a["default"].use(c.a,"ws://"+location.host+"/ws",{format:"json",reconnection:!0}),a["default"].config.productionTip=!1;var Q=[{path:"/",component:H}],Z=new r["a"]({routes:Q});new a["default"]({router:Z,render:function(t){return t(K)}}).$mount("#app")},6131:function(t,e,n){"use strict";var a=n("5004"),r=n.n(a);r.a},"82b7":function(t,e,n){},"9f4d":function(t,e,n){"use strict";var a=n("1ab3"),r=n.n(a);r.a},c21b:function(t,e,n){},cfd9:function(t,e,n){},e486:function(t,e,n){"use strict";var a=n("3c61"),r=n.n(a);r.a}}); 2 | //# sourceMappingURL=app.7e10ce6b.js.map -------------------------------------------------------------------------------- /web/dist/js/app.7e10ce6b.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./src/App.vue?4199","webpack:///./src/components/ScreenGrid.vue?1708","webpack:///./src/components/NavBar.vue?7be5","webpack:///./src/filters.js","webpack:///./src/App.vue?5161","webpack:///./src/components/NavBar.vue?7529","webpack:///src/components/NavBar.vue","webpack:///./src/components/NavBar.vue?e8e4","webpack:///./src/components/NavBar.vue","webpack:///./src/components/Player.vue?e5e0","webpack:///./src/components/PlayerBar.vue?835a","webpack:///./src/components/PlaylistModal.vue?00ee","webpack:///src/components/PlaylistModal.vue","webpack:///./src/components/PlaylistModal.vue?098c","webpack:///./src/components/PlaylistModal.vue","webpack:///src/components/PlayerBar.vue","webpack:///./src/components/PlayerBar.vue?d403","webpack:///./src/components/PlayerBar.vue","webpack:///./src/components/ScreenGrid.vue?c9b5","webpack:///src/components/ScreenGrid.vue","webpack:///./src/components/ScreenGrid.vue?3e2e","webpack:///./src/components/ScreenGrid.vue","webpack:///src/components/Player.vue","webpack:///./src/components/Player.vue?c370","webpack:///./src/components/Player.vue","webpack:///src/App.vue","webpack:///./src/App.vue?1160","webpack:///./src/App.vue","webpack:///./src/main.js","webpack:///./src/components/Player.vue?aae0","webpack:///./src/components/PlayerBar.vue?9584","webpack:///./src/components/PlaylistModal.vue?ed8b"],"names":["webpackJsonpCallback","data","moduleId","chunkId","chunkIds","moreModules","executeModules","i","resolves","length","installedChunks","push","Object","prototype","hasOwnProperty","call","modules","parentJsonpFunction","shift","deferredModules","apply","checkDeferredModules","result","deferredModule","fulfilled","j","depId","splice","__webpack_require__","s","installedModules","app","exports","module","l","m","c","d","name","getter","o","defineProperty","enumerable","get","r","Symbol","toStringTag","value","t","mode","__esModule","ns","create","key","bind","n","object","property","p","jsonpArray","window","oldJsonpFunction","slice","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_App_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0__","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_App_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0___default","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_ScreenGrid_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0__","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_ScreenGrid_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0___default","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_NavBar_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0__","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_NavBar_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0___default","Vue","filter","hours","parseInt","Math","floor","minutes","seconds","dHours","dMins","dSecs","Appvue_type_template_id_b51b35c4_render","_vm","this","_h","$createElement","_c","_self","attrs","id","staticRenderFns","NavBarvue_type_template_id_1a3d3978_render","toggleable","type","variant","target","href","_v","is-nav","staticClass","to","NavBarvue_type_template_id_1a3d3978_staticRenderFns","NavBarvue_type_script_lang_js_","components","components_NavBarvue_type_script_lang_js_","component","componentNormalizer","options","__file","NavBar","Playervue_type_template_id_3c78357e_render","clientConfig","serverState","client_config","clients","playerState","player","on","play","$event","Playervue_type_template_id_3c78357e_staticRenderFns","PlayerBarvue_type_template_id_db8a2516_render","$emit","disabled","current_media_filename","click","placeholder","_f","position","max","duration","domProps","textContent","_s","directives","rawName","modifiers","playlistModal","PlayerBarvue_type_template_id_db8a2516_staticRenderFns","PlaylistModalvue_type_template_id_3cd2be0c_render","hide-footer","title","_l","media_filename","size","deleteMedia","_e","show","dismissible","err","ref","dropzoneOptions","vdropzone-file-added","vdropzone-complete","uploadComplete","$refs","dropzone","vdropzone-error","uploadError","PlaylistModalvue_type_template_id_3cd2be0c_staticRenderFns","PlaylistModalvue_type_script_lang_js_","draggable","vuedraggable_default","a","vue2Dropzone","vue2Dropzone_default","props","required","url","thumbnailWidth","maxFilesize","methods","removeAllFiles","syncMedia","JSON","parse","xhr","response","reason","filename","$socket","sendObj","command","arguments","components_PlaylistModalvue_type_script_lang_js_","PlaylistModal_component","PlaylistModal","PlayerBarvue_type_script_lang_js_","components_PlayerBarvue_type_script_lang_js_","PlayerBar_component","PlayerBar","ScreenGridvue_type_template_id_115dd8f9_render","style","width","screenGrid","slot","clientsModal","staticStyle","margin-right","editState","cancelEdit","applyEdit","edit","screenGridLayoutClicked","client","ip","clientActive","username","media_path","age","submit","preventDefault","addScreen","clientIpOptions","model","callback","$$v","$set","expression","height","cursor","active","layout","screens","colNum","rowHeight","maxRows","isDraggable","isResizable","isMirrored","margin","verticalCompact","autoSize","useCssTransforms","screen","x","y","w","h","footer","removeScreen","ScreenGridvue_type_template_id_115dd8f9_staticRenderFns","ScreenGridvue_type_script_lang_js_","VueGridLayout","vue_grid_layout_common_default","Array","addScreenIp","clickedPosition","watch","_clientConfig","videocrop_config","left","top","right","bottom","computed","_this","forEach","config","addModal","hide","e","getAttribute","offsetX","offsetY","components_ScreenGridvue_type_script_lang_js_","ScreenGrid_component","ScreenGrid","Playervue_type_script_lang_js_","created","$options","sockets","onmessage","messageReceived","onopen","newConnection","console","log","components_Playervue_type_script_lang_js_","Player_component","Player","Appvue_type_script_lang_js_","src_Appvue_type_script_lang_js_","App_component","App","use","VueRouter","BootstrapVue","Icon","VueNativeSock","location","host","format","reconnection","productionTip","routes","path","router","render","$mount","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_Player_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0__","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_Player_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0___default","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_PlayerBar_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0__","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_PlayerBar_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0___default","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_PlaylistModal_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0__","_node_modules_mini_css_extract_plugin_dist_loader_js_ref_6_oneOf_1_0_node_modules_css_loader_index_js_ref_6_oneOf_1_1_node_modules_vue_loader_lib_loaders_stylePostLoader_js_node_modules_postcss_loader_lib_index_js_ref_6_oneOf_1_2_node_modules_cache_loader_dist_cjs_js_ref_0_0_node_modules_vue_loader_lib_index_js_vue_loader_options_PlaylistModal_vue_vue_type_style_index_0_lang_css___WEBPACK_IMPORTED_MODULE_0___default"],"mappings":"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","file":"js/app.7e10ce6b.js","sourcesContent":[" \t// install a JSONP callback for chunk loading\n \tfunction webpackJsonpCallback(data) {\n \t\tvar chunkIds = data[0];\n \t\tvar moreModules = data[1];\n \t\tvar executeModules = data[2];\n\n \t\t// add \"moreModules\" to the modules object,\n \t\t// then flag all \"chunkIds\" as loaded and fire callback\n \t\tvar moduleId, chunkId, i = 0, resolves = [];\n \t\tfor(;i < chunkIds.length; i++) {\n \t\t\tchunkId = chunkIds[i];\n \t\t\tif(installedChunks[chunkId]) {\n \t\t\t\tresolves.push(installedChunks[chunkId][0]);\n \t\t\t}\n \t\t\tinstalledChunks[chunkId] = 0;\n \t\t}\n \t\tfor(moduleId in moreModules) {\n \t\t\tif(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {\n \t\t\t\tmodules[moduleId] = moreModules[moduleId];\n \t\t\t}\n \t\t}\n \t\tif(parentJsonpFunction) parentJsonpFunction(data);\n\n \t\twhile(resolves.length) {\n \t\t\tresolves.shift()();\n \t\t}\n\n \t\t// add entry modules from loaded chunk to deferred list\n \t\tdeferredModules.push.apply(deferredModules, executeModules || []);\n\n \t\t// run deferred modules when all chunks ready\n \t\treturn checkDeferredModules();\n \t};\n \tfunction checkDeferredModules() {\n \t\tvar result;\n \t\tfor(var i = 0; i < deferredModules.length; i++) {\n \t\t\tvar deferredModule = deferredModules[i];\n \t\t\tvar fulfilled = true;\n \t\t\tfor(var j = 1; j < deferredModule.length; j++) {\n \t\t\t\tvar depId = deferredModule[j];\n \t\t\t\tif(installedChunks[depId] !== 0) fulfilled = false;\n \t\t\t}\n \t\t\tif(fulfilled) {\n \t\t\t\tdeferredModules.splice(i--, 1);\n \t\t\t\tresult = __webpack_require__(__webpack_require__.s = deferredModule[0]);\n \t\t\t}\n \t\t}\n \t\treturn result;\n \t}\n\n \t// The module cache\n \tvar installedModules = {};\n\n \t// object to store loaded and loading chunks\n \t// undefined = chunk not loaded, null = chunk preloaded/prefetched\n \t// Promise = chunk loading, 0 = chunk loaded\n \tvar installedChunks = {\n \t\t\"app\": 0\n \t};\n\n \tvar deferredModules = [];\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"/\";\n\n \tvar jsonpArray = window[\"webpackJsonp\"] = window[\"webpackJsonp\"] || [];\n \tvar oldJsonpFunction = jsonpArray.push.bind(jsonpArray);\n \tjsonpArray.push = webpackJsonpCallback;\n \tjsonpArray = jsonpArray.slice();\n \tfor(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);\n \tvar parentJsonpFunction = oldJsonpFunction;\n\n\n \t// add entry module to deferred list\n \tdeferredModules.push([0,\"chunk-vendors\"]);\n \t// run deferred modules when ready\n \treturn checkDeferredModules();\n","import mod from \"-!../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../node_modules/cache-loader/dist/cjs.js??ref--0-0!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&lang=css&\"; export default mod; export * from \"-!../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../node_modules/cache-loader/dist/cjs.js??ref--0-0!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&lang=css&\"","import mod from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ScreenGrid.vue?vue&type=style&index=0&lang=css&\"; export default mod; export * from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ScreenGrid.vue?vue&type=style&index=0&lang=css&\"","import mod from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./NavBar.vue?vue&type=style&index=0&lang=css&\"; export default mod; export * from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./NavBar.vue?vue&type=style&index=0&lang=css&\"","import Vue from \"vue\"\n\nVue.filter(\"hoursMinutesSeconds\", function(value) {\n let hours = parseInt(Math.floor(value / 3600)); \n let minutes = parseInt(Math.floor((value - (hours * 3600)) / 60)); \n let seconds= parseInt((value - ((hours * 3600) + (minutes * 60))) % 60); \n\n let dHours = (hours > 9 ? hours : '0' + hours);\n let dMins = (minutes > 9 ? minutes : '0' + minutes);\n let dSecs = (seconds > 9 ? seconds : '0' + seconds);\n\n return dHours + \":\" + dMins + \":\" + dSecs;\n})","var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{attrs:{\"id\":\"app\"}},[_c('NavBar'),_c('Player')],1)}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('b-navbar',{attrs:{\"toggleable\":\"md\",\"type\":\"dark\",\"variant\":\"info\",\"id\":\"navbar\"}},[_c('b-container',[_c('b-navbar-toggle',{attrs:{\"target\":\"nav_collapse\"}}),_c('b-navbar-brand',{attrs:{\"href\":\"#\"}},[_vm._v(\"Video wall\")]),_c('b-collapse',{attrs:{\"is-nav\":\"\",\"id\":\"nav_collapse\"}},[_c('b-navbar-nav',{staticClass:\"ml-auto\"},[_c('b-nav-item',{attrs:{\"to\":\"/player\"}},[_vm._v(\"Player\")])],1)],1)],1)],1)}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./NavBar.vue?vue&type=script&lang=js&\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./NavBar.vue?vue&type=script&lang=js&\"","import { render, staticRenderFns } from \"./NavBar.vue?vue&type=template&id=1a3d3978&\"\nimport script from \"./NavBar.vue?vue&type=script&lang=js&\"\nexport * from \"./NavBar.vue?vue&type=script&lang=js&\"\nimport style0 from \"./NavBar.vue?vue&type=style&index=0&lang=css&\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\ncomponent.options.__file = \"NavBar.vue\"\nexport default component.exports","var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{attrs:{\"id\":\"videoWall\"}},[_c('ScreenGrid',{attrs:{\"clientConfig\":_vm.serverState.client_config,\"clients\":_vm.serverState.clients}}),_c('center',[_c('PlayerBar',{attrs:{\"playerState\":_vm.serverState.player},on:{\"play\":function($event){_vm.play($event)}}})],1)],1)}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{attrs:{\"id\":\"playerBar\"}},[_c('PlaylistModal',{attrs:{\"playerState\":_vm.playerState},on:{\"play\":function($event){_vm.$emit('play', $event)}}}),_c('b-button-group',[_c('b-button',{attrs:{\"variant\":\"outline-secondary\"}},[_c('v-icon',{attrs:{\"name\":\"backward\"}})],1),_c('b-button',{attrs:{\"variant\":\"outline-secondary\",\"disabled\":!_vm.playerState.current_media_filename},on:{\"click\":function($event){_vm.$emit('play', _vm.playerState.current_media_filename)}}},[_c('v-icon',{attrs:{\"name\":\"sync-alt\"}})],1),_c('b-button',{attrs:{\"variant\":\"outline-secondary\"}},[_c('v-icon',{attrs:{\"name\":\"forward\"}})],1)],1),_c('div',{staticClass:\"playerTime\"},[_c('b-form-input',{attrs:{\"type\":\"text\",\"placeholder\":_vm._f(\"hoursMinutesSeconds\")(_vm.playerState.position),\"disabled\":\"\"}})],1),_c('div',{attrs:{\"id\":\"playerProgress\"}},[_c('b-progress',{attrs:{\"max\":_vm.playerState.duration,\"variant\":\"info\"}},[_c('b-progress-bar',{attrs:{\"value\":_vm.playerState.position},domProps:{\"textContent\":_vm._s(_vm.playerState.current_media_filename)}})],1)],1),_c('div',{staticClass:\"playerTime\"},[_c('b-form-input',{attrs:{\"type\":\"text\",\"placeholder\":_vm._f(\"hoursMinutesSeconds\")(_vm.playerState.duration),\"disabled\":\"\"}})],1),_c('b-button-group',[_c('b-button',{directives:[{name:\"b-modal\",rawName:\"v-b-modal.playlistModal\",modifiers:{\"playlistModal\":true}}],attrs:{\"variant\":\"outline-secondary\"}},[_c('v-icon',{attrs:{\"name\":\"list-ul\"}})],1)],1)],1)}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('b-modal',{attrs:{\"id\":\"playlistModal\",\"hide-footer\":\"\",\"title\":\"Playlist\"}},[_c('b-list-group',_vm._l((_vm.playerState.media_filenames),function(media_filename){return _c('b-list-group-item',{key:media_filename,staticClass:\"flex-column align-items-start\",attrs:{\"variant\":_vm.playerState.current_media_filename == media_filename ? 'info' : ''}},[_c('div',[_c('div',{staticClass:\"d-flex w-100 justify-content-between\"},[_c('h5',{staticClass:\"mb-1\",domProps:{\"textContent\":_vm._s(media_filename)}}),_c('b-button-group',{staticClass:\"playlistVideoButtons\",attrs:{\"size\":\"sm\"}},[_c('b-button',{attrs:{\"variant\":\"outline-secondary\"},on:{\"click\":function($event){_vm.$emit('play', media_filename)}}},[(_vm.playerState.current_media_filename != media_filename)?_c('v-icon',{attrs:{\"name\":\"play\"}}):_c('v-icon',{attrs:{\"name\":\"sync-alt\"}})],1),(_vm.playerState.current_media_filename != media_filename)?_c('b-button',{attrs:{\"variant\":\"outline-secondary\"},on:{\"click\":function($event){_vm.deleteMedia(media_filename)}}},[_c('v-icon',{attrs:{\"name\":\"times\"}})],1):_vm._e()],1)],1)])])})),(_vm.err)?_c('b-alert',{attrs:{\"variant\":\"danger\",\"show\":\"\",\"dismissible\":\"\"},domProps:{\"textContent\":_vm._s(_vm.err)}}):_vm._e(),_c('vue2-dropzone',{ref:\"dropzone\",attrs:{\"id\":\"dropzone\",\"options\":_vm.dropzoneOptions},on:{\"vdropzone-file-added\":function($event){_vm.err = ''},\"vdropzone-complete\":function($event){_vm.uploadComplete(_vm.$refs.dropzone)},\"vdropzone-error\":_vm.uploadError}})],1)}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlaylistModal.vue?vue&type=script&lang=js&\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlaylistModal.vue?vue&type=script&lang=js&\"","import { render, staticRenderFns } from \"./PlaylistModal.vue?vue&type=template&id=3cd2be0c&\"\nimport script from \"./PlaylistModal.vue?vue&type=script&lang=js&\"\nexport * from \"./PlaylistModal.vue?vue&type=script&lang=js&\"\nimport style0 from \"./PlaylistModal.vue?vue&type=style&index=0&lang=css&\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\ncomponent.options.__file = \"PlaylistModal.vue\"\nexport default component.exports","\n\n\n\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlayerBar.vue?vue&type=script&lang=js&\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlayerBar.vue?vue&type=script&lang=js&\"","import { render, staticRenderFns } from \"./PlayerBar.vue?vue&type=template&id=db8a2516&\"\nimport script from \"./PlayerBar.vue?vue&type=script&lang=js&\"\nexport * from \"./PlayerBar.vue?vue&type=script&lang=js&\"\nimport style0 from \"./PlayerBar.vue?vue&type=style&index=0&lang=css&\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\ncomponent.options.__file = \"PlayerBar.vue\"\nexport default component.exports","var render = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('b-card',{staticClass:\"mx-auto\",style:({width: ((_vm.screenGrid.w) + \"px\")}),attrs:{\"id\":\"screenGrid\"}},[_c('div',{attrs:{\"slot\":\"header\"},slot:\"header\"},[_c('b-button-toolbar',{staticClass:\"float-sm-right\",attrs:{\"size\":\"sm\"}},[_c('b-button',{directives:[{name:\"b-modal\",rawName:\"v-b-modal.clientsModal\",modifiers:{\"clientsModal\":true}}],staticStyle:{\"margin-right\":\"5px\"},attrs:{\"variant\":\"outline-secondary\"}},[_c('v-icon',{attrs:{\"name\":\"users\"}})],1),(_vm.editState.active)?_c('b-button-group',{staticClass:\"mx-1\"},[_c('b-button',{attrs:{\"variant\":\"outline-secondary\"},on:{\"click\":_vm.cancelEdit}},[_c('v-icon',{attrs:{\"name\":\"times\"}})],1),_c('b-button',{attrs:{\"variant\":\"outline-secondary\"},on:{\"click\":_vm.applyEdit}},[_c('v-icon',{attrs:{\"name\":\"check\"}})],1)],1):_c('b-button',{attrs:{\"variant\":\"outline-secondary\"},on:{\"click\":_vm.edit}},[_c('v-icon',{attrs:{\"name\":\"edit\"}})],1)],1)],1),_c('div',{attrs:{\"id\":\"screenGridLayout\"},on:{\"click\":function($event){_vm.screenGridLayoutClicked($event)}}},[_c('b-modal',{attrs:{\"id\":\"clientsModal\",\"hide-footer\":\"\",\"title\":\"Clients\"}},[_c('b-list-group',[_c('b-button',{on:{\"click\":_vm.syncMedia}},[_vm._v(\"Sync media\")]),_vm._l((_vm.clients),function(client){return _c('b-list-group-item',{key:client.ip,staticClass:\"flex-column align-items-start\",attrs:{\"variant\":_vm.clientActive(client.ip) ? 'success' : 'danger'}},[_c('div',[_c('div',{staticClass:\"d-flex justify-content-between align-items-center\"},[_c('table',{staticClass:\"clientTable\"},[_c('tr',[_c('td',[_vm._v(\"IP:\")]),_c('td',{domProps:{\"textContent\":_vm._s(client.ip)}})]),_c('tr',[_c('td',[_vm._v(\"Username:\")]),_c('td',{domProps:{\"textContent\":_vm._s(client.username)}})]),_c('tr',[_c('td',[_vm._v(\"Media path:\")]),_c('td',{domProps:{\"textContent\":_vm._s(client.media_path)}})])]),_c('b-badge',{attrs:{\"variant\":\"info\"}},[_vm._v(\"Age: \"+_vm._s(client.age))])],1)])])})],2)],1),_c('b-modal',{ref:\"addModal\",attrs:{\"hide-footer\":\"\",\"title\":\"Add screen for client\"}},[_c('div',{staticClass:\"d-block text-center\"},[_c('b-form',{on:{\"submit\":function($event){$event.preventDefault();_vm.addScreen()}}},[_c('b-form-select',{attrs:{\"options\":_vm.clientIpOptions,\"required\":\"\"},model:{value:(_vm.editState.addScreenIp),callback:function ($$v) {_vm.$set(_vm.editState, \"addScreenIp\", $$v)},expression:\"editState.addScreenIp\"}}),_c('b-button',{attrs:{\"type\":\"submit\",\"variant\":\"primary\"}},[_vm._v(\"Add screen\")])],1)],1)]),_c('grid-layout',{style:({height: ((_vm.screenGrid.h) + \"px\"), cursor: _vm.editState.active ? 'cell' : 'default'}),attrs:{\"layout\":_vm.screens,\"colNum\":1280,\"rowHeight\":1,\"maxRows\":720,\"isDraggable\":_vm.editState.active,\"isResizable\":_vm.editState.active,\"isMirrored\":false,\"margin\":[0, 0],\"verticalCompact\":false,\"autoSize\":true,\"useCssTransforms\":true}},_vm._l((_vm.screens),function(screen){return _c('grid-item',{key:screen.i,staticClass:\"screen\",style:({cursor: _vm.editState.active ? 'grabbing' : 'default'}),attrs:{\"x\":screen.x,\"y\":screen.y,\"w\":screen.w,\"h\":screen.h,\"i\":screen.i}},[_c('b-card',{attrs:{\"footer\":((screen.x) + \",\" + (screen.y) + \" [\" + (screen.w) + \"x\" + (screen.h) + \"]\")}},[_c('div',{attrs:{\"slot\":\"header\"},slot:\"header\"},[_c('span',{domProps:{\"textContent\":_vm._s((\"Screen \" + (screen.i)))}}),_c('b-button-toolbar',{staticClass:\"float-sm-right\"},[_c('b-button-group',{staticClass:\"mx-1\",attrs:{\"size\":\"sm\"}},[_c('b-button',{attrs:{\"variant\":_vm.clientActive(screen.i) ? 'success': 'danger'}},[_c('v-icon',{attrs:{\"name\":\"plug\"}})],1),(_vm.editState.active)?_c('b-button',{attrs:{\"variant\":\"danger\"},on:{\"click\":function($event){_vm.removeScreen(screen.i)}}},[_c('v-icon',{attrs:{\"name\":\"trash-alt\"}})],1):_vm._e()],1)],1)],1)])],1)}))],1)])}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","\n\n\n\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ScreenGrid.vue?vue&type=script&lang=js&\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./ScreenGrid.vue?vue&type=script&lang=js&\"","import { render, staticRenderFns } from \"./ScreenGrid.vue?vue&type=template&id=115dd8f9&\"\nimport script from \"./ScreenGrid.vue?vue&type=script&lang=js&\"\nexport * from \"./ScreenGrid.vue?vue&type=script&lang=js&\"\nimport style0 from \"./ScreenGrid.vue?vue&type=style&index=0&lang=css&\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\ncomponent.options.__file = \"ScreenGrid.vue\"\nexport default component.exports","\n\n\n\n\n","import mod from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Player.vue?vue&type=script&lang=js&\"; export default mod; export * from \"-!../../node_modules/cache-loader/dist/cjs.js??ref--12-0!../../node_modules/thread-loader/dist/cjs.js!../../node_modules/babel-loader/lib/index.js!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Player.vue?vue&type=script&lang=js&\"","import { render, staticRenderFns } from \"./Player.vue?vue&type=template&id=3c78357e&\"\nimport script from \"./Player.vue?vue&type=script&lang=js&\"\nexport * from \"./Player.vue?vue&type=script&lang=js&\"\nimport style0 from \"./Player.vue?vue&type=style&index=0&lang=css&\"\n\n\n/* normalize component */\nimport normalizer from \"!../../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\ncomponent.options.__file = \"Player.vue\"\nexport default component.exports","\n\n\n\n\n","import mod from \"-!../node_modules/cache-loader/dist/cjs.js??ref--12-0!../node_modules/thread-loader/dist/cjs.js!../node_modules/babel-loader/lib/index.js!../node_modules/cache-loader/dist/cjs.js??ref--0-0!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&\"; export default mod; export * from \"-!../node_modules/cache-loader/dist/cjs.js??ref--12-0!../node_modules/thread-loader/dist/cjs.js!../node_modules/babel-loader/lib/index.js!../node_modules/cache-loader/dist/cjs.js??ref--0-0!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=script&lang=js&\"","import { render, staticRenderFns } from \"./App.vue?vue&type=template&id=b51b35c4&\"\nimport script from \"./App.vue?vue&type=script&lang=js&\"\nexport * from \"./App.vue?vue&type=script&lang=js&\"\nimport style0 from \"./App.vue?vue&type=style&index=0&lang=css&\"\n\n\n/* normalize component */\nimport normalizer from \"!../node_modules/vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\ncomponent.options.__file = \"App.vue\"\nexport default component.exports","import Vue from 'vue'\n\nimport VueRouter from 'vue-router'\nVue.use(VueRouter)\n\nimport BootstrapVue from 'bootstrap-vue'\nVue.use(BootstrapVue);\nimport 'bootstrap/dist/css/bootstrap.css'\nimport 'bootstrap-vue/dist/bootstrap-vue.css'\n\nimport Icon from 'vue-awesome/components/Icon'\nimport 'vue-awesome/icons'\nVue.component('v-icon', Icon)\n\nimport VueNativeSock from 'vue-native-websocket'\nVue.use(VueNativeSock, \"ws://\" + location.host + \"/ws\", {\n format: 'json',\n reconnection: true\n})\n\nimport \"./filters\"\n\nimport App from './App.vue'\nimport Player from './components/Player.vue'\n\nVue.config.productionTip = false\n\nconst routes = [\n { path: '/', component: Player }\n]\n\nconst router = new VueRouter({\n routes\n})\n\nnew Vue({\n router,\n render: h => h(App)\n}).$mount('#app')\n","import mod from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Player.vue?vue&type=style&index=0&lang=css&\"; export default mod; export * from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./Player.vue?vue&type=style&index=0&lang=css&\"","import mod from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlayerBar.vue?vue&type=style&index=0&lang=css&\"; export default mod; export * from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlayerBar.vue?vue&type=style&index=0&lang=css&\"","import mod from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlaylistModal.vue?vue&type=style&index=0&lang=css&\"; export default mod; export * from \"-!../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--6-oneOf-1-0!../../node_modules/css-loader/index.js??ref--6-oneOf-1-1!../../node_modules/vue-loader/lib/loaders/stylePostLoader.js!../../node_modules/postcss-loader/lib/index.js??ref--6-oneOf-1-2!../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../node_modules/vue-loader/lib/index.js??vue-loader-options!./PlaylistModal.vue?vue&type=style&index=0&lang=css&\""],"sourceRoot":""} --------------------------------------------------------------------------------