├── .gitignore ├── LICENSE ├── README.md ├── bin ├── camplayer └── logsaver.sh ├── camplayer.service ├── camplayer ├── __init__.py ├── backgroundgen.py ├── camplayer.py ├── screenmanager.py ├── streaminfo.py ├── utils │ ├── constants.py │ ├── globals.py │ ├── inputhandler.py │ ├── logger.py │ ├── settings.py │ └── utils.py └── windowmanager.py ├── examples ├── demo-3x3grid-config.ini ├── demo-config.ini └── demo-pi4_dual_display-config.ini ├── install.sh ├── resources ├── backgrounds │ ├── nolink_1P12.png │ ├── nolink_1P5.png │ ├── nolink_1P7.png │ ├── nolink_1x1.png │ ├── nolink_2P8.png │ ├── nolink_2x2.png │ ├── nolink_3P4.png │ ├── nolink_3x3.png │ └── nolink_4x4.png ├── icons │ ├── icon_control.png │ ├── icon_loading.png │ └── icon_paused.png └── video │ ├── LICENSE.txt │ ├── big_buck_bunny_1080p.mp4 │ ├── big_buck_bunny_120p.mp4 │ ├── big_buck_bunny_360p.mp4 │ ├── big_buck_bunny_480p.mp4 │ ├── big_buck_bunny_720p.mp4 │ ├── big_buck_bunny_hevc_1080p.mp4 │ ├── elephants_dream_1080p.mp4 │ ├── elephants_dream_120p.mp4 │ ├── elephants_dream_360p.mp4 │ ├── elephants_dream_480p.mp4 │ ├── elephants_dream_720p.mp4 │ └── elephants_dream_hevc_1080p.mp4 ├── screenshots ├── camplayer_nolink.png └── camplayer_running.png └── tests ├── test-audio-config.ini ├── test-base16_grid-config.ini ├── test-base9_grid-config.ini ├── test-downscaling-config.ini ├── test-hevc-config.ini ├── test-performance_1080pH264-config.ini ├── test-performance_360pH264-config.ini ├── test-performance_360pH264_dualscreen-config.ini ├── test-performance_480pH264-config.ini ├── test-performance_480pH264_dualscreen-config.ini ├── test-performance_720pH264-config.ini └── test-video_osd-config.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Camplayer - IP Camera viewer for the Raspberry Pi 2 | Use your Raspberry Pi as advanced IP camera viewer. 3 | The list of supported IP cameras is endless as Camplayer makes use of the RTSP and HTTP protocols for streaming. 4 | Especially the RTSP protocol is supported by nearly all big brands out there. 5 | 6 | A picture is worth a thousand words. 7 | 8 | ![Camplayer 2x2 grid](./screenshots/camplayer_nolink.png) 9 | 10 | ## Website 11 | [https://www.rpi-camplayer.com/](https://www.rpi-camplayer.com/) 12 | 13 | ## Donate 14 | Please consider a donation if you like this software or want to support its ongoing development. 15 | 16 | 17 | 18 | ## Features 19 | * MPEG2, H264, MJPEG and experimental HEVC support (see support matrix). 20 | * Video grid for up to 16 streams. 21 | * Switch from grid to single view mode (zoom mode) and back. 22 | * Substream/subchannel support with automatic selection. 23 | * Automatic recovery of broken streams. 24 | * Keyboard navigation. 25 | * Easy configuration with .ini file 26 | * Experimental audio support. 27 | * Dual display support for the Raspberry Pi 4. 28 | * OSD and background support. 29 | * Demo mode. 30 | 31 | ## Support matrix 32 | | | Raspberry Pi Zero | Raspberry Pi 2 | Raspberry Pi 3/3+ | Raspberry Pi 4 | 33 | |:----------------------:|:-----------------:|:--------------:|:-----------------:|:--------------:| 34 | | H264 | Yes | Yes | Yes | Yes | 35 | | H265/HEVC | No | No | Yes (1,4) | Yes (2,4) | 36 | | MPEG2 | Yes | Yes | Yes | Yes (3,4) | 37 | | Dual display | No | No | No | Yes (4) | 38 | | Video OSD | Yes | Yes | Yes | No | 39 | 40 | (1) Full screen mode only, up to 1080p/FHD. 41 | (2) Full screen mode only, up to 2160p/4K. 42 | (3) Full screen mode only. 43 | (4) Experimental. 44 | 45 | General notes: 46 | * The maximum resolution for MJPEG, H264 and MPEG2 is 1920x1080. 47 | * You need to buy a license key for MPEG2. 48 | 49 | ## How many windows in grid view mode? 50 | 51 | The maximum number of windows/video players in grid view mode depends on many factors 52 | including display resolution, video resolution, framerate, codec, Pi hardware etc. 53 | 54 | The tables below give you an idea of what to expect with some common video resolutions. 55 | 56 | ### Single display 1920x1080@60Hz 57 | 58 | | | Raspberry Pi Zero | Raspberry Pi 2 | Raspberry Pi 3/3+ | Raspberry Pi 4 | 59 | |:---------------------------:|:-----------------:|:--------------:|:-----------------:|:--------------:| 60 | | H264 640x360@24FPS | ? | ? | 9 | 9 | 61 | | H264 854x480@24FPS | ? | ? | 7 | 7 | 62 | | H264 1280x720@24FPS | ? | ? | 4 | 4 | 63 | | H264 1920x1080@24FPS | 1 | 1 | 1 | 1 | 64 | 65 | ### Dual display 1920x1080@60Hz 66 | 67 | | | Raspberry Pi Zero | Raspberry Pi 2 | Raspberry Pi 3/3+ | Raspberry Pi 4 | 68 | |:---------------------------:|:-----------------:|:--------------:|:-----------------:|:--------------:| 69 | | H264 640x360@24FPS | N/A | N/A | N/A | 4 + 6 | 70 | | H264 854x480@24FPS | N/A | N/A | N/A | 1 + 6 | 71 | 72 | 73 | You can easely perform your own performance tests by running one of the test configs. 74 | 75 | ``` 76 | camplayer -c ../tests/test-performance_360pH264-config.ini 77 | ``` 78 | 79 | ## Security warnings 80 | 81 | * Password protected HTTP and RTSP streams are widely used on security cameras but are not 100% secure as these streams are still unencrypted. 82 | The only thing that is protected here is the login procedure. 83 | * Anyone with physical access to your Raspberry Pi and/or SD card can potentially retrieve your IP camera credentials. 84 | Passwords and usernames are saved as plain text. 85 | * It's better to disable SSH or at least change the default password. 86 | 87 | Therefore, please don't use this software if security is critical for you. 88 | I take no responsibility for leaked footage, leaked credentials, hacked cameras etc., you have been warned! 89 | 90 | ## Installation 91 | 92 | The instruction below assume you are running a recent Raspberry Pi OS Lite build. 93 | If you are interested in a plug & play system with some additional features, check out [https://www.rpi-camplayer.com/](https://www.rpi-camplayer.com/) 94 | 95 | Add the following lines to '/boot/config.txt': 96 | ``` 97 | gpu_mem=256 98 | disable_overscan=1 99 | ``` 100 | 101 | Comment out (or delete) the following line in '/boot/config.txt' and reboot: 102 | ``` 103 | # dtoverlay=vc4-fkms-v3d 104 | ``` 105 | 106 | Install git client: 107 | ``` 108 | sudo apt-get update 109 | sudo apt-get install git 110 | ``` 111 | 112 | Get the source code and check it out in the current directory: 113 | ``` 114 | git clone -b Camplayer_1.0 https://github.com/raspicamplayer/camplayer.git 115 | ``` 116 | 117 | Install camplayer by running the install script: 118 | ``` 119 | sudo sh ./camplayer/install.sh 120 | ``` 121 | 122 | Now you can test your install with: 123 | ``` 124 | camplayer --demo 125 | ``` 126 | 127 | After you finished your configuration (see next section), you can start camplayer with: 128 | ``` 129 | sudo systemctl start camplayer.service 130 | ``` 131 | 132 | To automatically start camplayer at boot: 133 | ``` 134 | sudo systemctl enable camplayer.service 135 | ``` 136 | 137 | To stop camplayer: 138 | ``` 139 | sudo systemctl stop camplayer.service 140 | ``` 141 | or 142 | Press "q" key. 143 | 144 | ## Experimental HEVC/H265 support 145 | You have to install an experimental VLC media player version, 146 | please follow the instructions of the following thread. 147 | https://www.raspberrypi.org/forums/viewtopic.php?f=29&t=257395 148 | 149 | It is important to note that VLC does not support windowed playback at the moment, so you can not create a grid layout of HEVC/H265 streams. 150 | Once VLC supports this, I will probably drop OMXplayer support and rewrite the windowmanager code in a less hackish way. 151 | 152 | ## Configuration 153 | ### Getting started 154 | Create and open following file (assumes you are running as user 'pi') 155 | ``` 156 | /home/pi/.camplayer/config.ini 157 | ``` 158 | 159 | Create device section(s). 160 | 161 | ```ini 162 | [DEVICE1] 163 | channel1_name = optional OSD name 164 | channel1.1_url = your 1st IP camera stream URL (main stream) 165 | channel1.2_url = your 1st IP camera stream URL (sub stream) 166 | 167 | [DEVICE2] 168 | channel1_name = optional OSD name 169 | channel1.1_url = your 2nd IP camera stream URL (main stream) 170 | channel1.2_url = your 2nd IP camera stream URL (sub stream) 171 | ``` 172 | It is important that device sections are named "**[DEVICEx]**" and channels are named "**channelx.y_url**". 173 | At least 1 sub-channel must be added, up to 9 sub-channels are possible. 174 | 175 | Create screen section(s) 176 | 177 | ```ini 178 | [SCREEN1] 179 | layout = 4 180 | window1 = device1,channel1 181 | window2 = device1,channel1 182 | window3 = device2,channel1 183 | window4 = device2,channel1 184 | ``` 185 | It is important that screen sections are named "**[SCREENx]**" and windows are named "**windowx**". 186 | The window values should be formatted as "**devicex,channelx**" and match the desired device section and channel you created in previous step. 187 | 188 | Another important point is that "**layout**" must be one of the following numbers "**1,4,6,7,8,9,10,13,16***" 189 | This number defines the layout and the number of windows per screen e.g. a layout of "6" gives you the following layout: 190 | https://github.com/raspicamplayer/camplayer/blob/master/resources/backgrounds/nolink_1P5.png 191 | 192 | You can find an overview of possible layouts here: 193 | https://github.com/raspicamplayer/camplayer/tree/master/resources/backgrounds 194 | 195 | ### Raspberry Pi dual display 196 | First of all, camplayer assumes that both displays are set to the same resolution. So please verify this and if nessesary adjust your display configuration. 197 | Now, to display a screen on the second HDMI (HDMI1), you have to add a 'display = 2' property to the desired SCREEN section. 198 | 199 | ```ini 200 | [SCREEN1] 201 | display = 2 202 | layout = 4 203 | window1 = device1,channel1 204 | ... 205 | ``` 206 | 207 | ## Troubleshooting 208 | 209 | ### Check logging output 210 | 211 | To be sure all instances are stopped: 212 | ``` 213 | sudo systemctl stop camplayer.service 214 | ``` 215 | 216 | Run camplayer from command line without 'sudo': 217 | ``` 218 | camplayer 219 | ``` 220 | 221 | Check the output of this command for errors and/or warnings. 222 | 223 | * For the Raspberry pi 4, use the HDMI0 labeled output 224 | * Unsupported hardware? -> see 'hardwarecheck' in advanced settings, no guarantee! 225 | * H265/HEVC streams are not supported in grid mode! 226 | 227 | ## Performance impact 228 | 229 | You can experience HDMI signal dropouts and/or video artifacts when your configuration exeeds the capabilities of your Raspberry Pi's GPU. 230 | It is hard to predict this in advance as it depends on many factors like. 231 | 232 | * Video resolution and framerate 233 | * Display resolution 234 | * GPU/H264 decoder clock frequency 235 | * Number of connected displays 236 | * Background mode (advanced setting) 237 | * Screen downscaling (advanced setting) 238 | * Video OSD (advanced setting) 239 | 240 | When in trouble, you can reduce the number of windows in the gridview to reduce the performance impact. 241 | Other options you can try are: 242 | 243 | * Set advanced setting "streamquality" to "0" (lowest quality) 244 | * Set advanced setting "backgroundmode" to "0" (black background) 245 | * Set advanced setting "screenchangeover" to "0" (normal changeover) 246 | * Don't set advanced setting "screendownscale" 247 | * Don't set advanced setting "enablevideoosd" 248 | * Use a faster/newer Raspberry Pi model 249 | * Reduce resolution/framerate inside your IP camera configuration 250 | * Scaling the video resolution to the window resolution comes at a cost, a perfect match is preferred. 251 | You can achieve this by chosing your IP camera and/or display resolution carefully. 252 | Important to note, upscaling is still better than downscaling performance wise. 253 | 254 | ## Advanced settings 255 | 256 | You can change advanced settings by adding an "ADVANCED" section to your configuration file. 257 | In this example we set the video buffertime to 1000ms. 258 | 259 | ### Example 260 | ```ini 261 | [ADVANCED] 262 | buffertime = 1000 263 | ``` 264 | 265 | ### Possible advanced settings 266 | 267 | ``` 268 | showtime Screen rotation interval in seconds when multiple screens are configured (0=no automatic rotation). 269 | loglevel Log level (0=debug, 1=info, 2=warning, 3=error). 270 | hardwarecheck Check hardware capabilities on startup (0=off, 1=on). 271 | backgroundmode Use background image, pipng install required 272 | (0=black, 1=static grid background, 2=dynamic background, 3=off). 273 | buffertime Video buffertime in millisecond. 274 | screenchangeover Changeover mode with multiple screens (0=normal, 1=fast, 2=smooth). 275 | icons Enable loading, paused, ... icons on top of the video (0=off, 1=on). 276 | streamwatchdog Check and repair broken video streams interval in seconds (0=off, 1=on). 277 | playtimeout Video play timeout in seconds. 278 | streamquality Stream selection when multiple subchannels are defined 279 | (0=lowest quality, 1=automatic, 2=highest quality). 280 | refreshtime Refresh streams interval in minutes. 281 | enablehevc HEVC/H265 support (0=disable, 1=auto select from hardware, 2=limit to FHD/1080p, 3=force on). 282 | enableaudio Enable audio support for fullscreen playing video. (0=off, 1=on when fullscreen). 283 | screendownscale Downscale the used screen area (adds a black border) in percent. 284 | enablevideoosd Show channel/camera name on top of each video (0=off, 1=on). 285 | audiovolume Default audio volume (0..100). 286 | screendownscale Downscale the virtual screen by x percent (0..100). 287 | screenwidth Forced screen width in pixels (if autodetect fails). 288 | screenheight Forced screen height in pixels (if autodetect fails). 289 | ``` 290 | ## Key bindings 291 | ``` 292 | space Pause/unpause automatic screen rotation. 293 | enter Switch from grid to single view mode. 294 | left/right arrow Switch to previous/next screen (or window in single view mode). 295 | up/down arrow Increase/decrease stream quality (if multiple subchannels/substreams configured). 296 | numeric keys 1..16 Switch from grid view mode to the relevant window in fullscreen (single view mode). 297 | numeric key 0 Switch from single view to grid view mode and unpause rotation. 298 | escape Switch from single view to grid view mode and unpause rotation. 299 | letter 'q' Quit camplayer. 300 | ``` 301 | 302 | ## Roadmap 303 | ### Camplayer 2 304 | * Improve VLC and drop OMXplayer support, drop code hacks introduced to support them both. 305 | * Proper windowed H265/HEVC playback, related to previous point. 306 | * PTZ support by calling external scripts. 307 | * Proper audio support. 308 | -------------------------------------------------------------------------------- /bin/camplayer: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # cd to set the correct working directory in python 4 | cd /usr/local/share/camplayer/camplayer 5 | ./camplayer.py "$@" 6 | -------------------------------------------------------------------------------- /bin/logsaver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | LOGFILE="camplayer.tail.log" 4 | DIRECTORY_BOOT="/boot/" 5 | DIRECTORY_ROOT="/usr/local/share/camplayer" 6 | MAXSIZE=500000 7 | NLINES=500 8 | 9 | check_logfile() { 10 | local path=$1$2 11 | 12 | if [ ! -f "$path" ]; then 13 | touch $path 14 | else 15 | local filesize=$(stat -c%s "$path") 16 | 17 | if [ $filesize -gt $MAXSIZE ]; then 18 | echo "" > $path 19 | fi 20 | fi 21 | 22 | echo "" >> $path 23 | echo "###################### Last $NLINES lines of logfile ######################" >> $path 24 | journalctl --unit=camplayer.service | tail -n $NLINES >> $path 25 | } 26 | 27 | if [ -d "$DIRECTORY_BOOT" ]; then 28 | check_logfile $DIRECTORY_BOOT $LOGFILE 29 | else 30 | check_logfile $DIRECTORY_ROOT $LOGFILE 31 | fi 32 | 33 | -------------------------------------------------------------------------------- /camplayer.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=IP camera player for the Raspberry Pi 3 | After=network-online.target 4 | Wants=network-online.target 5 | 6 | [Service] 7 | Type=simple 8 | User=pi 9 | Group=pi 10 | ExecStart=/usr/local/bin/camplayer 11 | # ExecStopPost=bash /usr/local/share/camplayer/bin/logsaver.sh 12 | KillMode=control-group 13 | Restart=on-failure 14 | RestartSec=30 15 | Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /camplayer/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /camplayer/backgroundgen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import subprocess 4 | import time 5 | import os 6 | 7 | from utils.logger import LOG 8 | from utils.settings import BACKGROUND, CONFIG 9 | from utils.constants import CONSTANTS 10 | from utils.globals import GLOBALS 11 | 12 | 13 | class BackGround(object): 14 | 15 | # Overlays in front of video 16 | LOADING = "icon_loading.png" 17 | PAUSED = "icon_paused.png" 18 | CONTROL = "icon_control.png" 19 | 20 | # Backgrounds behind video 21 | NOLINK_1X1 = "nolink_1x1.png" 22 | NOLINK_2X2 = "nolink_2x2.png" 23 | NOLINK_3X3 = "nolink_3x3.png" 24 | NOLINK_4X4 = "nolink_4x4.png" 25 | NOLINK_1P5 = "nolink_1P5.png" 26 | NOLINK_1P7 = "nolink_1P7.png" 27 | NOLINK_1P12 = "nolink_1P12.png" 28 | NOLINK_2P8 = "nolink_2P8.png" 29 | NOLINK_3P4 = "nolink_3P4.png" 30 | 31 | @classmethod 32 | def NOLINK(cls, window_count): 33 | """Get NO LINK image background based on window count""" 34 | 35 | _map = ({ 36 | 1: cls.NOLINK_1X1, 37 | 4: cls.NOLINK_2X2, 38 | 6: cls.NOLINK_1P5, 39 | 7: cls.NOLINK_3P4, 40 | 8: cls.NOLINK_1P7, 41 | 9: cls.NOLINK_3X3, 42 | 10: cls.NOLINK_2P8, 43 | 13: cls.NOLINK_1P12, 44 | 16: cls.NOLINK_4X4 45 | }) 46 | 47 | file_path = str("%s%s_%i_%i.png" % (CONSTANTS.CACHE_DIR, _map.get(window_count).split('.png')[0], 48 | CONSTANTS.VIRT_SCREEN_WIDTH, CONSTANTS.VIRT_SCREEN_HEIGHT)) 49 | 50 | if os.path.isfile(file_path): 51 | return file_path 52 | 53 | if BackGroundManager.scale_background( 54 | src_path=CONSTANTS.RESOURCE_DIR_BCKGRND + _map.get(window_count), dest_path=file_path, 55 | dest_width=CONSTANTS.VIRT_SCREEN_WIDTH, dest_height=CONSTANTS.VIRT_SCREEN_HEIGHT): 56 | return file_path 57 | 58 | return "" 59 | 60 | 61 | class BackGroundManager(object): 62 | 63 | _MODULE = "BackGroundManager" 64 | 65 | _proc_icons = [None for _ in range(GLOBALS.NUM_DISPLAYS)] 66 | _proc_instant_icon = [None for _ in range(GLOBALS.NUM_DISPLAYS)] 67 | _proc_background = [None for _ in range(GLOBALS.NUM_DISPLAYS)] 68 | _icons = [[] for _ in range(GLOBALS.NUM_DISPLAYS)] 69 | _backgrounds = [[] for _ in range(GLOBALS.NUM_DISPLAYS)] 70 | 71 | active_icon = ["" for _ in range(GLOBALS.NUM_DISPLAYS)] 72 | active_icon_display = ["" for _ in range(GLOBALS.NUM_DISPLAYS)] 73 | active_background = ["" for _ in range(GLOBALS.NUM_DISPLAYS)] 74 | 75 | _background_layer = -100 # Must be higher than -127 to hide the framebuffer 76 | _foreground_layer = 1000 # Must be higher than the OMXplayer layers 77 | 78 | @classmethod 79 | def show_icon_instant(cls, filename, display_idx=0): 80 | """Show png icon in front of video immediately""" 81 | 82 | if not CONFIG.ENABLE_ICONS or not GLOBALS.PIPNG_SUPPORT: 83 | return 84 | 85 | display_idx = 1 if display_idx == 1 else 0 86 | 87 | cls.hide_icon_instant(display_idx) 88 | 89 | pngview_cmd = ["pipng", 90 | "-b", "0", # No 2nd background layer under image 91 | "-l", str(cls._foreground_layer + 1), # Set layer number 92 | "-d", "2" if display_idx == 0 else "7", # Set display number 93 | "-x", str(CONSTANTS.ICON_OFFSET_X), # 60px offset x-axis 94 | "-y", str(CONSTANTS.ICON_OFFSET_Y), # 60px offset y-axis 95 | "-n", # Non interactive mode 96 | "-h", # Hide lower layers 97 | str(CONSTANTS.RESOURCE_DIR_ICONS + filename) 98 | ] 99 | 100 | cls._proc_instant_icon[display_idx] = \ 101 | subprocess.Popen(pngview_cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 102 | 103 | @classmethod 104 | def hide_icon_instant(cls, display_idx=0): 105 | """Hide icon loaded with the instant method""" 106 | 107 | if not CONFIG.ENABLE_ICONS or not GLOBALS.PIPNG_SUPPORT: 108 | return 109 | 110 | display_idx = 1 if display_idx == 1 else 0 111 | 112 | if cls._proc_instant_icon[display_idx]: 113 | cls._proc_instant_icon[display_idx].terminate() 114 | cls._proc_instant_icon[display_idx] = None 115 | 116 | @classmethod 117 | def add_icon(cls, filename, display_idx=0): 118 | """Add icon to pipng queue""" 119 | 120 | display_idx = 1 if display_idx == 1 else 0 121 | 122 | # Already present? -> ignore 123 | for image in cls._icons[display_idx]: 124 | if image == filename: 125 | return 126 | 127 | cls._icons[display_idx].append(filename) 128 | 129 | @classmethod 130 | def add_background(cls, window_count=1, display_idx=0): 131 | """Add background to pipng queue""" 132 | 133 | display_idx = 1 if display_idx == 1 else 0 134 | 135 | file_path = BackGround.NOLINK(window_count=window_count) 136 | 137 | if not file_path: 138 | return 139 | 140 | # Already present? -> ignore 141 | for image in cls._backgrounds[display_idx]: 142 | if image == file_path: 143 | return 144 | 145 | cls._backgrounds[display_idx].append(file_path) 146 | 147 | @classmethod 148 | def load_backgrounds(cls): 149 | """Load pipng background queue""" 150 | 151 | if CONFIG.BACKGROUND_MODE == BACKGROUND.OFF or not GLOBALS.PIPNG_SUPPORT: 152 | return 153 | 154 | if CONFIG.BACKGROUND_MODE == BACKGROUND.HIDE_FRAMEBUFFER: 155 | 156 | for display_idx in range(GLOBALS.NUM_DISPLAYS): 157 | 158 | if len(cls._backgrounds[display_idx]) <= 0: 159 | continue 160 | 161 | subprocess.Popen(["pipng", "-b", "000F", "-n", "-d", "2" if display_idx == 0 else "7"], 162 | shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 163 | 164 | else: 165 | 166 | static_background = CONFIG.BACKGROUND_MODE == BACKGROUND.STATIC 167 | 168 | for display_idx in range(GLOBALS.NUM_DISPLAYS): 169 | 170 | if len(cls._backgrounds[display_idx]) <= 0: 171 | continue 172 | 173 | pngview_cmd = ["pipng", 174 | "-b", "0", # No 2nd background layer under image 175 | "-l", str(cls._background_layer), # Set layer number 176 | "-d", "2" if display_idx == 0 else "7", # Set display number 177 | "-h", # Hide lower layers (less GPU performance impact) 178 | ] 179 | 180 | if not static_background: 181 | pngview_cmd.append("-i") # Start with all images invisible 182 | 183 | # Add all background images, currently limited to 10 184 | for image in cls._backgrounds[display_idx]: 185 | pngview_cmd.append(image) 186 | 187 | # TODO: find best match with static backgrounds and multiple screens 188 | if static_background: 189 | break 190 | 191 | LOG.DEBUG(cls._MODULE, "Loading pipng for display '%i' with command '%s'" % 192 | (display_idx + 1, pngview_cmd)) 193 | 194 | cls._proc_background[display_idx] = \ 195 | subprocess.Popen(pngview_cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 196 | 197 | @classmethod 198 | def load_icons(cls): 199 | """Load pipng icon queue""" 200 | 201 | if not CONFIG.ENABLE_ICONS or not GLOBALS.PIPNG_SUPPORT: 202 | return 203 | 204 | for display_idx in range(GLOBALS.NUM_DISPLAYS): 205 | 206 | if len(cls._icons[display_idx]) <= 0: 207 | continue 208 | 209 | pngview_cmd = ["pipng", 210 | "-b", "0", # No 2nd background layer under image 211 | "-l", str(cls._foreground_layer), # Set layer number 212 | "-d", "2" if display_idx == 0 else "7", # Set display number 213 | "-i", # Start with all images invisible 214 | "-x", str(CONSTANTS.ICON_OFFSET_X), # 60px offset x-axis 215 | "-y", str(CONSTANTS.ICON_OFFSET_Y), # 60px offset y-axis 216 | ] 217 | 218 | # Add all images, currently limited to 10 219 | for image in cls._icons[display_idx]: 220 | pngview_cmd.append(CONSTANTS.RESOURCE_DIR_ICONS + image) 221 | 222 | LOG.DEBUG(cls._MODULE, "Loading pipng for display '%i' with command '%s'" % 223 | (display_idx, pngview_cmd)) 224 | 225 | cls._proc_icons[display_idx] = \ 226 | subprocess.Popen(pngview_cmd, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 227 | 228 | @classmethod 229 | def show_icon(cls, filename, display_idx=0): 230 | """Show pipng icon from queue""" 231 | 232 | if not CONFIG.ENABLE_ICONS or not GLOBALS.PIPNG_SUPPORT: 233 | return 234 | 235 | display_idx = 1 if display_idx == 1 else 0 236 | 237 | # Show new image/icon 238 | for idx, image in enumerate(cls._icons[display_idx]): 239 | if filename == image: 240 | LOG.DEBUG(cls._MODULE, "setting icon '%s' visible for display '%i" % (filename, display_idx)) 241 | cls._proc_icons[display_idx].stdin.write(str(idx).encode('utf-8')) 242 | cls._proc_icons[display_idx].stdin.flush() 243 | 244 | cls.active_icon[display_idx] = filename 245 | 246 | @classmethod 247 | def hide_icon(cls, display_idx=0): 248 | """Hide active pipng iconn""" 249 | 250 | if not CONFIG.ENABLE_ICONS or not GLOBALS.PIPNG_SUPPORT: 251 | return 252 | 253 | display_idx = 1 if display_idx == 1 else 0 254 | 255 | if not cls.active_icon[display_idx]: 256 | return 257 | 258 | LOG.DEBUG(cls._MODULE, "hiding icon '%s' for display '%i" % (cls.active_icon[display_idx], display_idx)) 259 | 260 | cls._proc_icons[display_idx].stdin.write("i".encode('utf-8')) 261 | cls._proc_icons[display_idx].stdin.flush() 262 | 263 | cls.active_icon[display_idx] = "" 264 | 265 | # pipng needs some milliseconds to read stdin 266 | # Especially important when hide_icon() will be immediately followed by show_icon() 267 | time.sleep(0.025) 268 | 269 | @classmethod 270 | def show_background(cls, filename, display_idx=0): 271 | """Show pipng background from queue""" 272 | 273 | if CONFIG.BACKGROUND_MODE != BACKGROUND.DYNAMIC or not GLOBALS.PIPNG_SUPPORT: 274 | return 275 | 276 | display_idx = 1 if display_idx == 1 else 0 277 | 278 | if cls.active_background[display_idx] == filename: 279 | return 280 | 281 | # Show new image/icon 282 | for idx, image in enumerate(cls._backgrounds[display_idx]): 283 | if filename == image: 284 | LOG.DEBUG(cls._MODULE, "setting background '%s' visible for display '%i" % (filename, display_idx)) 285 | cls._proc_background[display_idx].stdin.write(str(idx).encode('utf-8')) 286 | cls._proc_background[display_idx].stdin.flush() 287 | 288 | cls.active_background[display_idx] = filename 289 | 290 | @classmethod 291 | def scale_background(cls, src_path, dest_path, dest_width, dest_height): 292 | """Scale background image to the requested width and height""" 293 | 294 | if not GLOBALS.FFMPEG_SUPPORT: 295 | return False 296 | 297 | ffmpeg_cmd = str("ffmpeg -i '%s' -vf scale=%i:%i '%s'" % (src_path, dest_width, dest_height, dest_path)) 298 | 299 | try: 300 | subprocess.check_output(ffmpeg_cmd, shell=True, stderr=subprocess.STDOUT, timeout=5) 301 | 302 | except (subprocess.CalledProcessError, subprocess.TimeoutExpired): 303 | LOG.ERROR(cls._MODULE, "Scaling background image '%s' failed" % src_path) 304 | 305 | if os.path.isfile(dest_path): 306 | return True 307 | 308 | return False 309 | 310 | @classmethod 311 | def destroy(cls): 312 | """Destroy pipng instances""" 313 | 314 | if not GLOBALS.PIPNG_SUPPORT: 315 | return 316 | 317 | if CONFIG.ENABLE_ICONS: 318 | for display_idx in range(GLOBALS.NUM_DISPLAYS): 319 | if cls._proc_icons[display_idx]: 320 | cls._proc_icons[display_idx].stdin.write("c".encode('utf-8')) 321 | 322 | if CONFIG.BACKGROUND_MODE == BACKGROUND.DYNAMIC: 323 | for display_idx in range(GLOBALS.NUM_DISPLAYS): 324 | if cls._proc_background[display_idx]: 325 | cls._proc_background[display_idx].stdin.write("c".encode('utf-8')) -------------------------------------------------------------------------------- /camplayer/camplayer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import sys 4 | import os 5 | import time 6 | import platform 7 | import signal 8 | 9 | from utils.logger import LOG 10 | from utils.settings import CONFIG, HEVCMODE, BACKGROUND 11 | from utils.constants import CONSTANTS, KEYCODE 12 | from utils.globals import GLOBALS 13 | from utils.inputhandler import InputMonitor 14 | from utils import utils 15 | 16 | from backgroundgen import BackGroundManager, BackGround 17 | from screenmanager import ScreenManager 18 | from screenmanager import Action 19 | 20 | running = True 21 | 22 | _LOG_NAME = "Main" 23 | __version__ = "1.0.0.dev" 24 | 25 | 26 | def signal_handler(signum, frame): 27 | """SIGTERM/SIGINT callback, terminate our application...""" 28 | 29 | global running 30 | running = False 31 | 32 | 33 | def clear_cache(): 34 | """Clear our cache directory""" 35 | 36 | if os.path.isdir(CONSTANTS.CACHE_DIR): 37 | for filename in os.listdir(CONSTANTS.CACHE_DIR): 38 | os.remove(os.path.join(CONSTANTS.CACHE_DIR, filename)) 39 | 40 | 41 | def main(): 42 | """Application entry point""" 43 | 44 | global running 45 | 46 | num_array = [] 47 | last_added = time.monotonic() 48 | ignore_quit = False 49 | 50 | if not platform.system() == "Linux": 51 | sys.exit("'%s' OS not supported!" % platform.system()) 52 | 53 | if os.geteuid() == 0: 54 | sys.exit("Camplayer is not supposed to be run as root!") 55 | 56 | GLOBALS.PYTHON_VER = sys.version_info 57 | if GLOBALS.PYTHON_VER < CONSTANTS.PYTHON_VER_MIN: 58 | sys.exit("Python version '%i.%i' or newer required!" 59 | % (CONSTANTS.PYTHON_VER_MIN[0], CONSTANTS.PYTHON_VER_MIN[1])) 60 | 61 | # Started with arguments? 62 | if len(sys.argv) > 1: 63 | for idx, arg in enumerate(sys.argv): 64 | 65 | # 1st argument is application 66 | if idx == 0: 67 | continue 68 | 69 | # Help info 70 | if arg == "-h" or arg == "--help": 71 | print(" -h --help Print this help") 72 | print(" -v --version Print version info") 73 | print(" -c --config Use a specific config file") 74 | print(" --rebuild-cache Rebuild cache on startup") 75 | print(" --rebuild-cache-exit Rebuild cache and exit afterwards") 76 | print(" -d --demo Demo mode") 77 | print(" --ignorequit Don't quit when the 'Q' key is pressed") 78 | sys.exit(0) 79 | 80 | # Run in a specific mode 81 | if arg == "--rebuild-cache" or arg == "--rebuild-cache-exit": 82 | 83 | # Clearing the cache 84 | clear_cache() 85 | 86 | # Rebuild cache only and exit 87 | if arg == "--rebuild-cache-exit": 88 | 89 | # Exit when reaching the main loop 90 | running = False 91 | 92 | # Run with a specific config file 93 | if arg == "-c" or arg == "--config" and (idx + 1) < len(sys.argv): 94 | CONSTANTS.CONFIG_PATH = sys.argv[idx + 1] 95 | 96 | # Display version info 97 | if arg == "-v" or arg == "--version": 98 | print("version " + __version__) 99 | sys.exit(0) 100 | 101 | # Run demo mode 102 | if arg == "-d" or arg == "--demo": 103 | CONSTANTS.CONFIG_PATH = CONSTANTS.DEMO_CONFIG_PATH 104 | 105 | # Ignore keyboard 'quit' command 106 | if arg == "--ignorequit": 107 | ignore_quit = True 108 | 109 | # Create folder if not exist 110 | if not os.path.isdir(os.path.dirname(CONSTANTS.APPDATA_DIR)): 111 | print("Creating config folder '%s'" % CONSTANTS.APPDATA_DIR) 112 | os.system("mkdir -p %s" % os.path.dirname(CONSTANTS.APPDATA_DIR)) 113 | 114 | # Load settings from config file 115 | CONFIG.load() 116 | 117 | # Signal handlers 118 | signal.signal(signal.SIGTERM, signal_handler) 119 | signal.signal(signal.SIGINT, signal_handler) 120 | 121 | LOG.INFO(_LOG_NAME, "Starting camplayer version %s" % __version__) 122 | LOG.INFO(_LOG_NAME, "Using config file '%s' and cache directory '%s'" 123 | % (CONSTANTS.CONFIG_PATH, CONSTANTS.CACHE_DIR)) 124 | 125 | # Cleanup some stuff in case something went wrong on the previous run 126 | utils.kill_service('omxplayer.bin', force=True) 127 | utils.kill_service('vlc', force=True) 128 | utils.kill_service('pipng', force=True) 129 | 130 | # OMXplayer is absolutely required! 131 | if not utils.os_package_installed("omxplayer.bin"): 132 | sys.exit("OMXplayer not installed but required!") 133 | 134 | # ffprobe is absolutely required! 135 | if not utils.os_package_installed("ffprobe"): 136 | sys.exit("ffprobe not installed but required!") 137 | 138 | # Get system info 139 | sys_info = utils.get_system_info() 140 | gpu_mem = utils.get_gpu_memory() 141 | hw_info = utils.get_hardware_info() 142 | 143 | # Set some globals for later use 144 | GLOBALS.PI_SOC = hw_info.get("soc") # Not very reliable, usually reports BCM2835 145 | GLOBALS.PI_MODEL = hw_info.get("model") 146 | GLOBALS.PI_SOC_HEVC = hw_info.get('hevc') 147 | GLOBALS.NUM_DISPLAYS = 2 if hw_info.get('dual_hdmi') else 1 148 | GLOBALS.VLC_SUPPORT = utils.os_package_installed("vlc") 149 | GLOBALS.PIPNG_SUPPORT = utils.os_package_installed("pipng") 150 | GLOBALS.FFMPEG_SUPPORT = utils.os_package_installed("ffmpeg") 151 | GLOBALS.USERNAME = os.getenv('USER') 152 | 153 | # Log system info 154 | LOG.INFO(_LOG_NAME, "********************** SYSTEM INFO **********************") 155 | LOG.INFO(_LOG_NAME, str("Camplayer version = %s" % __version__)) 156 | LOG.INFO(_LOG_NAME, str("Operating system = %s" % sys_info)) 157 | LOG.INFO(_LOG_NAME, str("Raspberry Pi SoC = %s" % hw_info.get("soc"))) 158 | LOG.INFO(_LOG_NAME, str("Raspberry Pi revision = %s" % hw_info.get("revision"))) 159 | LOG.INFO(_LOG_NAME, str("Raspberry Pi model name = %s" % hw_info.get("model"))) 160 | LOG.INFO(_LOG_NAME, str("GPU memory allocation = %i MB" % gpu_mem)) 161 | LOG.INFO(_LOG_NAME, str("Python version = %s MB" % sys.version.splitlines()[0])) 162 | LOG.INFO(_LOG_NAME, str("VLC installed = %s" % GLOBALS.VLC_SUPPORT)) 163 | LOG.INFO(_LOG_NAME, str("pipng installed = %s" % GLOBALS.PIPNG_SUPPORT)) 164 | LOG.INFO(_LOG_NAME, str("ffmpeg installed = %s" % GLOBALS.FFMPEG_SUPPORT)) 165 | LOG.INFO(_LOG_NAME, "*********************************************************") 166 | 167 | # Register for keyboard 'press' events, requires root 168 | # TODO: check privileges? 169 | keyboard = InputMonitor(event_type=['press']) 170 | 171 | # Log overwrites for debugging purpose 172 | for setting in CONFIG.advanced_overwritten: 173 | LOG.INFO(_LOG_NAME, "advanced setting overwritten for '%s' is '%s'" % (setting[0], setting[1])) 174 | 175 | # Does this system fulfill the minimal requirements 176 | if CONFIG.HARDWARE_CHECK: 177 | if not hw_info.get("supported"): 178 | sys.exit("Unsupported hardware with revision %s ..." % hw_info.get("revision")) 179 | 180 | if gpu_mem < CONSTANTS.MIN_GPU_MEM: 181 | sys.exit("GPU memory of '%i' MB insufficient ..." % gpu_mem) 182 | 183 | # Auto detect screen resolution 184 | # For the raspberry pi 4: 185 | # both HDMI displays are supposed to have the same configuration 186 | if CONFIG.SCREEN_HEIGHT == 0 or CONFIG.SCREEN_WIDTH == 0: 187 | display_conf = utils.get_display_mode() 188 | CONFIG.SCREEN_HEIGHT = display_conf.get('res_height') 189 | CONFIG.SCREEN_WIDTH = display_conf.get('res_width') 190 | LOG.INFO(_LOG_NAME, "Detected screen resolution for HDMI0 is '%ix%i@%iHz'" % ( 191 | CONFIG.SCREEN_WIDTH, CONFIG.SCREEN_HEIGHT, display_conf.get('framerate'))) 192 | 193 | if CONFIG.SCREEN_HEIGHT <= 0: 194 | CONFIG.SCREEN_HEIGHT = 1080 195 | if CONFIG.SCREEN_WIDTH <= 0: 196 | CONFIG.SCREEN_WIDTH = 1920 197 | 198 | # Are we sure the 2nd HDMI is on for dual HDMI versions? 199 | if GLOBALS.NUM_DISPLAYS == 2: 200 | # Check for resolution instead of display name as the latter one is empty with force HDMI hotplug 201 | if not utils.get_display_mode(display=7).get('res_height'): 202 | GLOBALS.NUM_DISPLAYS = 1 203 | 204 | # Calculate the virtual screen size now that we now the physical screen size 205 | CONSTANTS.VIRT_SCREEN_WIDTH = int(CONFIG.SCREEN_WIDTH * (100 - CONFIG.SCREEN_DOWNSCALE) / 100) 206 | CONSTANTS.VIRT_SCREEN_HEIGHT = int(CONFIG.SCREEN_HEIGHT * (100 - CONFIG.SCREEN_DOWNSCALE) / 100) 207 | CONSTANTS.VIRT_SCREEN_OFFSET_X = int((CONFIG.SCREEN_WIDTH - CONSTANTS.VIRT_SCREEN_WIDTH) / 2) 208 | CONSTANTS.VIRT_SCREEN_OFFSET_Y = int((CONFIG.SCREEN_HEIGHT - CONSTANTS.VIRT_SCREEN_HEIGHT) / 2) 209 | LOG.INFO(_LOG_NAME, "Using a virtual screen resolution of '%ix%i'" % 210 | (CONSTANTS.VIRT_SCREEN_WIDTH, CONSTANTS.VIRT_SCREEN_HEIGHT)) 211 | 212 | # Workaround: srt subtitles have a maximum display time of 99 hours 213 | if CONFIG.VIDEO_OSD and (not CONFIG.REFRESHTIME_MINUTES or CONFIG.REFRESHTIME_MINUTES >= 99 * 60): 214 | CONFIG.REFRESHTIME_MINUTES = 99 * 60 215 | LOG.WARNING(_LOG_NAME, "Subtitle based OSD enabled, forcing 'refreshtime' to '%i'" % CONFIG.REFRESHTIME_MINUTES) 216 | 217 | # Show 'loading' on master display 218 | BackGroundManager.show_icon_instant(BackGround.LOADING, display_idx=0) 219 | 220 | # Initialize screens and windows 221 | screenmanager = ScreenManager() 222 | if screenmanager.valid_screens < 1: 223 | sys.exit("No valid screen configuration found, check your config file!") 224 | 225 | # Hide 'loading' message on master display 226 | BackGroundManager.hide_icon_instant(display_idx=0) 227 | 228 | # Working loop 229 | while running: 230 | 231 | # Trigger screenmanager working loop 232 | screenmanager.do_work() 233 | 234 | for event in keyboard.get_events(): 235 | last_added = time.monotonic() 236 | 237 | if event.code in KEYCODE.KEY_NUM.keys(): 238 | LOG.DEBUG(_LOG_NAME, "Numeric key event: %i" % KEYCODE.KEY_NUM.get(event.code)) 239 | 240 | num_array.append(KEYCODE.KEY_NUM.get(event.code)) 241 | 242 | # Two digit for numbers from 0 -> 99 243 | if len(num_array) > 2: 244 | num_array.pop(0) 245 | else: 246 | 247 | # Non numeric key, clear numeric num_array 248 | num_array.clear() 249 | 250 | if event.code == KEYCODE.KEY_RIGHT: 251 | screenmanager.on_action(Action.SWITCH_NEXT) 252 | 253 | elif event.code == KEYCODE.KEY_LEFT: 254 | screenmanager.on_action(Action.SWITCH_PREV) 255 | 256 | elif event.code == KEYCODE.KEY_UP: 257 | screenmanager.on_action(Action.SWITCH_QUALITY_UP) 258 | 259 | elif event.code == KEYCODE.KEY_DOWN: 260 | screenmanager.on_action(Action.SWITCH_QUALITY_DOWN) 261 | 262 | elif event.code == KEYCODE.KEY_ENTER or event.code == KEYCODE.KEY_KPENTER: 263 | screenmanager.on_action(Action.SWITCH_SINGLE, 0) 264 | 265 | elif event.code == KEYCODE.KEY_ESC or event.code == KEYCODE.KEY_EXIT: 266 | screenmanager.on_action(Action.SWITCH_GRID) 267 | 268 | elif event.code == KEYCODE.KEY_SPACE: 269 | screenmanager.on_action(Action.SWITCH_PAUSE_UNPAUSE) 270 | 271 | elif event.code == KEYCODE.KEY_D: 272 | screenmanager.on_action(Action.SWITCH_DISPLAY_CONTROL) 273 | 274 | elif event.code == KEYCODE.KEY_Q and not ignore_quit: 275 | running = False 276 | 277 | break 278 | 279 | # Timeout between key presses expired? 280 | if time.monotonic() > (last_added + (CONSTANTS.KEY_TIMEOUT_MS / 1000)): 281 | num_array.clear() 282 | 283 | # 1 second delay to accept multiple digit numbers 284 | elif time.monotonic() > (last_added + (CONSTANTS.KEY_MULTIDIGIT_MS / 1000)) and len(num_array) > 0: 285 | 286 | LOG.INFO(_LOG_NAME, "Process numeric key input '%s'" % str(num_array)) 287 | 288 | number = 0 289 | number += num_array[-2] * 10 if len(num_array) > 1 else 0 290 | number += num_array[-1] 291 | 292 | if number == 0: 293 | num_array.clear() 294 | screenmanager.on_action(Action.SWITCH_GRID) 295 | else: 296 | num_array.clear() 297 | screenmanager.on_action(Action.SWITCH_SINGLE, number - 1) 298 | 299 | time.sleep(0.1) 300 | 301 | # Cleanup stuff before exit 302 | keyboard.destroy() 303 | BackGroundManager.destroy() 304 | utils.kill_service('omxplayer.bin', force=True) 305 | utils.kill_service('vlc', force=True) 306 | utils.kill_service('pipng', force=True) 307 | 308 | LOG.INFO(_LOG_NAME, "Exiting raspberry pi camplayer, have a nice day!") 309 | sys.exit(0) 310 | 311 | 312 | if __name__ == "__main__": 313 | main() -------------------------------------------------------------------------------- /camplayer/streaminfo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import json 5 | import subprocess 6 | 7 | from urllib.parse import urlparse, urlunparse 8 | from utils.logger import LOG 9 | from utils.settings import CONFIG, HEVCMODE 10 | from utils.constants import CONSTANTS 11 | from utils.globals import GLOBALS 12 | 13 | 14 | class StreamInfo(object): 15 | 16 | _LOG_NAME = "StreamInfo" 17 | 18 | def __init__(self, stream_url): 19 | 20 | # Make absolute paths from relative ones 21 | if stream_url.startswith('file://.'): 22 | stream_url = "file://" + os.path.abspath(stream_url.lstrip('file:/')) 23 | 24 | self.url = stream_url 25 | self._cache_file = CONSTANTS.CACHE_DIR + "streaminfo" 26 | self.codec_name = "" 27 | self.height = 0 28 | self.width = 0 29 | self.framerate = 0 30 | self.has_audio = False 31 | self.force_udp = False 32 | self._parse_stream_details() 33 | 34 | self.valid_url = self._is_url_valid() 35 | self.valid_video_windowed = self._is_video_valid(windowed=True) 36 | self.valid_video_fullscreen = self._is_video_valid(windowed=False) 37 | self.weight = self._calculate_weight() 38 | self.quality = self.width * self.height 39 | 40 | LOG.INFO(self._LOG_NAME, "stream properties '%s', resolution '%ix%i@%i', codec '%s', " 41 | "calculated weight '%i', valid url '%i', has audio '%s', " 42 | "valid video 'windowed %i fullscreen %i', force UDP '%s'" % ( 43 | self.printable_url(), self.width, self.height, self.framerate, 44 | self.codec_name, self.weight, self.valid_url, self.has_audio, 45 | self.valid_video_windowed, self.valid_video_fullscreen, self.force_udp)) 46 | LOG.INFO(self._LOG_NAME, "RUN 'camplayer --rebuild-cache' IF THIS STREAM INFORMATION IS OUT OF DATE!!") 47 | 48 | def printable_url(self): 49 | """Returns streaming url without readable username and password""" 50 | 51 | parsed = urlparse(self.url) 52 | 53 | if parsed.username or parsed.password: 54 | parsed = parsed._replace(netloc=str("xxx:yyy@%s:%s" % (parsed.hostname, parsed.port))) 55 | 56 | return urlunparse(parsed) 57 | 58 | def _calculate_weight(self): 59 | """Calculate performance impact for the hardware video decoder""" 60 | 61 | if not self.valid_url or (not self.valid_video_windowed and not self.valid_video_fullscreen): 62 | return 0 63 | 64 | # HEVC decoding does not involve the GPU 65 | if self.codec_name == "hevc": 66 | return 0 67 | 68 | # OMXplayer plays at min 10FPS 69 | return self.width * self.height * max(self.framerate, 10) 70 | 71 | def _is_url_valid(self): 72 | """True when url format is valid""" 73 | 74 | if (self.url.startswith('rtsp://') or 75 | self.url.startswith('http://') or 76 | self.url.startswith('https://') or 77 | self.url.startswith('file://')): 78 | return True 79 | return False 80 | 81 | def _is_video_valid(self, windowed=True): 82 | """True when the video format is valid for pi hardware""" 83 | 84 | # Model 4 SoC does not support hardware MPEG2 decoding anymore 85 | omx_mpeg2_support = not "4B" in GLOBALS.PI_MODEL 86 | 87 | if CONFIG.HEVC_MODE == HEVCMODE.AUTO: 88 | 89 | # Model 4 SoC supports hardware HEVC decoding with VLC 90 | if "4B" in GLOBALS.PI_MODEL: 91 | CONFIG.HEVC_MODE = HEVCMODE.UHD 92 | 93 | # Model 3+ SoC should be able to decode FHD HEVC in software with VLC 94 | elif "3B+" in GLOBALS.PI_MODEL: 95 | CONFIG.HEVC_MODE = HEVCMODE.FHD 96 | 97 | else: 98 | CONFIG.HEVC_MODE = HEVCMODE.OFF 99 | 100 | # HEVC decoding is currently only supported by VLC, 101 | # which also means that windowed playback without X11 is not possible right now... 102 | if not windowed and GLOBALS.VLC_SUPPORT: 103 | 104 | if self.codec_name == 'hevc': 105 | if CONFIG.HEVC_MODE == HEVCMODE.FHD and \ 106 | self.width <= 1920 and self.height <= 1080: 107 | return True 108 | 109 | if CONFIG.HEVC_MODE == HEVCMODE.UHD and \ 110 | self.width <= 3840 and self.height <= 2160: 111 | return True 112 | 113 | elif self.codec_name == 'mpeg2video': 114 | if self.width <= 1920 and self.height <= 1080: 115 | return True 116 | 117 | 118 | # Hardware / OMXplayer supported codecs 119 | # 1080p is the hard limit of the hardware decoder 120 | # PI4 does not support MPEG2 in HW 121 | if (self.codec_name == 'h264' or 122 | self.codec_name == 'mjpeg' or 123 | (self.codec_name == 'mpeg2video' and 124 | omx_mpeg2_support)) and \ 125 | self.width <= 1920 and \ 126 | self.height <= 1080: 127 | return True 128 | 129 | return False 130 | 131 | def _parse_stream_details(self): 132 | """Read stream details from cache file or parse stream directly""" 133 | 134 | if not self._is_url_valid(): 135 | return 136 | 137 | parsed_ok = False 138 | video_found = False 139 | 140 | if os.path.isfile(self._cache_file): 141 | with open(self._cache_file, 'r') as stream_file: 142 | data = json.load(stream_file) 143 | 144 | if self.printable_url() in data.keys(): 145 | stream_props = data.get(self.printable_url()) 146 | self.codec_name = stream_props.get('codec_name') 147 | self.height = stream_props.get('height') 148 | self.width = stream_props.get('width') 149 | self.framerate = stream_props.get('framerate') 150 | self.has_audio = stream_props.get('audio') 151 | self.force_udp = stream_props.get('force_udp') 152 | parsed_ok = True 153 | 154 | if not parsed_ok: 155 | for i in range(2): 156 | 157 | # Most cameras are using TCP, so test for TCP first. If that fails, test with UDP. 158 | transport = 'udp' if i > 0 else 'tcp' 159 | 160 | try: 161 | ffprobe_args = ['ffprobe', '-v', 'error', '-show_entries', 162 | 'stream=codec_type,height,width,codec_name,bit_rate,max_bit_rate,avg_frame_rate', 163 | self.url] 164 | 165 | if self.url.startswith('rtsp://'): 166 | ffprobe_args.extend(['-rtsp_transport', transport]) 167 | 168 | # Invoke ffprobe, 20s timeout required for pi zero 169 | streams = subprocess.check_output(ffprobe_args, universal_newlines=True, timeout=10, 170 | stderr=subprocess.STDOUT).split("[STREAM]") 171 | 172 | for stream in streams: 173 | streamprops = stream.split() 174 | 175 | if "codec_type=video" in stream and not video_found: 176 | video_found = True 177 | 178 | for streamproperty in streamprops: 179 | if "codec_name" in streamproperty: 180 | self.codec_name = streamproperty.split("=")[1] 181 | if "height" in streamproperty: 182 | self.height = int(streamproperty.split("=")[1]) 183 | if "width" in streamproperty: 184 | self.width = int(streamproperty.split("=")[1]) 185 | if "avg_frame_rate" in streamproperty: 186 | try: 187 | framerate = streamproperty.split("=")[1] 188 | 189 | # ffprobe returns framerate as fraction, 190 | # a zero division exception is therefore possible 191 | self.framerate = int( 192 | framerate.split("/")[0])/int(framerate.split("/")[1]) 193 | except Exception: 194 | self.framerate = 0 195 | 196 | elif "codec_type=audio" in stream and not self.has_audio: 197 | self.has_audio = True 198 | 199 | if video_found: 200 | try: 201 | self.force_udp = True if transport == 'udp' else False 202 | self._write_stream_details() 203 | break 204 | 205 | # TODO: filter read-only exception 206 | except Exception: 207 | LOG.ERROR(self._LOG_NAME, "writing ffprobe results to file failed, read only?") 208 | 209 | # TODO: logging exceptions can spawn credentials?? 210 | except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as ex: 211 | if i > 0: 212 | LOG.ERROR(self._LOG_NAME, "ffprobe exception: %s" % str(ex)) 213 | 214 | def _write_stream_details(self): 215 | """Write stream details to cache file""" 216 | 217 | if not self._is_url_valid(): 218 | return 219 | 220 | data = {self.printable_url(): { 221 | 'codec_name' : self.codec_name, 222 | 'height' : self.height, 223 | 'width' : self.width, 224 | 'framerate' : self.framerate, 225 | 'audio' : self.has_audio, 226 | 'force_udp' : self.force_udp, 227 | }} 228 | 229 | # Read stream details file and append our new data 230 | if os.path.isfile(self._cache_file): 231 | with open(self._cache_file) as stream_file: 232 | cur_data = json.load(stream_file) 233 | data.update(cur_data) 234 | 235 | # Create folder if not exist 236 | if not os.path.isdir(os.path.dirname(self._cache_file)): 237 | os.system("mkdir -p %s" % os.path.dirname(self._cache_file)) 238 | 239 | # Write stream details to file 240 | with open(self._cache_file, 'w+') as stream_file: 241 | json.dump(data, stream_file, indent=4) 242 | -------------------------------------------------------------------------------- /camplayer/utils/constants.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | 5 | 6 | class CONSTANTS(object): 7 | 8 | APPDATA_DIR = str("%s/.camplayer/" % os.path.expanduser('~')) # Application data directory 9 | RESOURCE_DIR_ICONS = "../resources/icons/" # Resource directory for icons 10 | RESOURCE_DIR_BCKGRND = "../resources/backgrounds/" # Resource directory for backgrounds 11 | DEMO_CONFIG_PATH = "../examples/demo-config.ini" # Demo config path 12 | CONFIG_PATH = str("%sconfig.ini" % APPDATA_DIR) # Default config path 13 | CACHE_DIR = str("%scache/" % APPDATA_DIR) # Cache directory for images and stream details 14 | 15 | WINDOW_OFFSET = 10000 # Offset for off screen (invisible) windows 16 | PLAYER_INITIALIZE_MS = 2000 # Player max initializing time 17 | HW_DEC_MAX_WEIGTH = 1920 * 1080 * 60 # PI hardware decoder limit (experimental) 18 | MAX_DECODER_STREAMS = 16 # OMXplayer hard limit 19 | DBUS_TIMEOUT_MS = 1000 # Timeout for dbus-send commands 20 | DBUS_RETRIES = 5 # Max dbus-send retries 21 | LOG_LINE_LEN = 170 # Logger line length in characters 22 | PYTHON_VER_MIN = (3, 7) # Minimum required Python version 23 | MIN_GPU_MEM = 256 # Mininum required GPU memory split 24 | MAX_SCREENS = 32 25 | MAX_WINDOWS = 16 26 | KEY_TIMEOUT_MS = 3000 27 | KEY_MULTIDIGIT_MS = 1000 28 | VIRT_SCREEN_WIDTH = 0 29 | VIRT_SCREEN_HEIGHT = 0 30 | VIRT_SCREEN_OFFSET_X = 0 31 | VIRT_SCREEN_OFFSET_Y = 0 32 | ICON_OFFSET_X = 60 33 | ICON_OFFSET_Y = 60 34 | 35 | 36 | class KEYCODE(object): 37 | """Linux keyboard scancodes""" 38 | 39 | KEY_D = 32 40 | KEY_Q = 16 41 | KEY_LEFT = 105 42 | KEY_RIGHT = 106 43 | KEY_UP = 103 44 | KEY_DOWN = 108 45 | KEY_ESC = 1 46 | KEY_EXIT = 174 47 | KEY_ENTER = 28 48 | KEY_KPENTER = 96 49 | KEY_SPACE = 57 50 | 51 | KEY_NUM = { 52 | # Key = scancode, value = number 53 | 54 | # Numeric codes 55 | 2: 1, 56 | 3: 2, 57 | 4: 3, 58 | 5: 4, 59 | 6: 5, 60 | 7: 6, 61 | 8: 7, 62 | 9: 8, 63 | 10: 9, 64 | 11: 0, 65 | 66 | # Keypad codes 67 | 79: 1, 68 | 80: 2, 69 | 81: 3, 70 | 75: 4, 71 | 76: 5, 72 | 77: 6, 73 | 71: 7, 74 | 72: 8, 75 | 73: 9, 76 | 82: 0, 77 | } -------------------------------------------------------------------------------- /camplayer/utils/globals.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | 4 | class GLOBALS(object): 5 | VLC_SUPPORT = False 6 | FFMPEG_SUPPORT = False 7 | PIPNG_SUPPORT = False 8 | NUM_DISPLAYS = 2 9 | PI_SOC = 0 10 | PI_MODEL = 0 11 | PI_SOC_HEVC = False 12 | PYTHON_VER = (0, 0) 13 | USERNAME = "" -------------------------------------------------------------------------------- /camplayer/utils/inputhandler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import time 4 | import threading 5 | 6 | import evdev 7 | import queue 8 | 9 | 10 | class InputMonitor(object): 11 | 12 | def __init__(self, event_type=['release', 'press', 'hold'], scan_interval=2500): 13 | self._devices = [] 14 | self._event_queue = queue.Queue(maxsize=10) 15 | self._scan_interval = scan_interval / 1000 16 | self._event_up = True if 'release' in event_type else False 17 | self._event_down = True if 'press' in event_type else False 18 | self._event_hold = True if 'hold' in event_type else False 19 | self._running = True 20 | self._monitor_thread = threading.Thread(target=self._monitor, daemon=True).start() 21 | 22 | def destroy(self): 23 | """Stop monitoring thread""" 24 | 25 | self._running = False 26 | 27 | def get_events(self): 28 | """Get queued keyboard events""" 29 | 30 | events = [] 31 | while not self._event_queue.empty(): 32 | event = self._event_queue.get_nowait() 33 | self._event_queue.task_done() 34 | events.append(event) 35 | 36 | return events 37 | 38 | def _scan_devices(self): 39 | """Scan for input devices""" 40 | 41 | return [evdev.InputDevice(path) for path in evdev.list_devices()] 42 | 43 | def _monitor(self): 44 | """Key monitoring thread""" 45 | 46 | last_scan_time = -self._scan_interval 47 | 48 | while self._running and threading.main_thread().is_alive(): 49 | 50 | if time.monotonic() > last_scan_time + self._scan_interval: 51 | self._devices = self._scan_devices() 52 | last_scan_time = time.monotonic() 53 | 54 | # TODO fix 55 | # Somehow evdev misses button presses in its own loop 56 | # Looping faster than the expected time between button presses, hides this issue... 57 | # https://github.com/gvalkov/python-evdev/issues/101 58 | time.sleep(0.025) 59 | 60 | for device in self._devices: 61 | try: 62 | while True: 63 | event = device.read_one() 64 | if event: 65 | if event.type == evdev.ecodes.EV_KEY: 66 | if self._event_up and event.value == 0: 67 | self._event_queue.put_nowait(event) 68 | elif self._event_down and event.value == 1: 69 | self._event_queue.put_nowait(event) 70 | elif self._event_hold and event.value == 2: 71 | self._event_queue.put_nowait(event) 72 | del event 73 | else: 74 | break 75 | except BlockingIOError: 76 | pass 77 | except OSError: 78 | pass 79 | except queue.Full: 80 | pass 81 | 82 | for device in self._devices: 83 | try: 84 | device.close() 85 | except: 86 | pass 87 | -------------------------------------------------------------------------------- /camplayer/utils/logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import datetime 4 | from enum import IntEnum 5 | from enum import unique 6 | 7 | from .settings import CONFIG 8 | from .constants import CONSTANTS 9 | 10 | 11 | @unique 12 | class LOGLEVEL(IntEnum): 13 | DEBUG = 0 14 | INFO = 1 15 | WARNING = 2 16 | ERROR = 3 17 | 18 | 19 | def _split_message(message): 20 | """Split log message in multiple lines if exceeding MAX_LINE_LEN""" 21 | 22 | if len(message) <= CONSTANTS.LOG_LINE_LEN: 23 | return [message] 24 | 25 | response = [] 26 | for i in range(0, len(message), CONSTANTS.LOG_LINE_LEN): 27 | response.append(message[i: (i + CONSTANTS.LOG_LINE_LEN)]) 28 | 29 | return response 30 | 31 | 32 | def _output_message(prefix, message): 33 | """Print log message to console""" 34 | 35 | lines = _split_message(message) 36 | for idx, line in enumerate(lines): 37 | if idx == 0: 38 | print("%s - %s" % (prefix, line)) 39 | else: 40 | print("%s --> %s" % (prefix, line)) 41 | 42 | 43 | def log_message(module, loglevel, message): 44 | """Format log message with log level and timestamp""" 45 | 46 | _output_message(str("%s - %s - %s" % (datetime.datetime.now(), module, loglevel.name)), str(message)) 47 | 48 | 49 | class LOG(object): 50 | 51 | @staticmethod 52 | def DEBUG(module, message): 53 | if CONFIG.LOG_LEVEL <= LOGLEVEL.DEBUG: 54 | log_message(module, LOGLEVEL.DEBUG, message) 55 | 56 | @staticmethod 57 | def INFO(module, message): 58 | if CONFIG.LOG_LEVEL <= LOGLEVEL.INFO: 59 | log_message(module, LOGLEVEL.INFO, message) 60 | 61 | @staticmethod 62 | def WARNING(module, message): 63 | if CONFIG.LOG_LEVEL <= LOGLEVEL.WARNING: 64 | log_message(module, LOGLEVEL.WARNING, message) 65 | 66 | @staticmethod 67 | def ERROR(module, message): 68 | if CONFIG.LOG_LEVEL <= LOGLEVEL.ERROR: 69 | log_message(module, LOGLEVEL.ERROR, message) 70 | -------------------------------------------------------------------------------- /camplayer/utils/settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | 6 | from enum import IntEnum 7 | from enum import unique 8 | from configparser import ConfigParser 9 | 10 | from . import logger 11 | from . import constants 12 | 13 | 14 | @unique 15 | class LAYOUT(IntEnum): 16 | _1X1 = 1 17 | _2X2 = 4 18 | _3X3 = 9 19 | _1P5 = 6 20 | _1P7 = 8 21 | _3P4 = 7 22 | _2P8 = 10 23 | _1P12 = 13 24 | _4X4 = 16 25 | 26 | 27 | @unique 28 | class CHANGEOVER(IntEnum): 29 | NORMAL = 0 # Stop streams of current screen, then start streams of new screen. 30 | # -> less bandwidth/active players but slow... 31 | PREBUFFER = 1 # Start streams of new screen in background to speedup changeover. 32 | # -> Uses more resources. 33 | PREBUFFER_SMOOTH = 2 # Similar as previous method but without the split second black screen. 34 | # -> Uses the most resources. 35 | 36 | 37 | @unique 38 | class BACKGROUND(IntEnum): 39 | HIDE_FRAMEBUFFER = 0 # No background and hide framebuffer 40 | STATIC = 1 # Grid background (1 static image) 41 | DYNAMIC = 2 # Grid background (image depending on active screen) 42 | OFF = 3 43 | 44 | 45 | @unique 46 | class STREAMQUALITY(IntEnum): 47 | LOW = 0 # Always use the lowest quality stream 48 | AUTO = 1 # Auto select the stream quality 49 | HIGH = 2 # Always use highest (sensible) quality stream 50 | 51 | 52 | @unique 53 | class HEVCMODE(IntEnum): 54 | OFF = 0 # Disable hevc decoding 55 | AUTO = 1 # Auto select based on pi model 56 | FHD = 2 # Enable hevc decoding up to FHD/1080p 57 | UHD = 3 # Enable hevc decoding up to UHD/4k 58 | 59 | @unique 60 | class AUDIOMODE(IntEnum): 61 | OFF = 0 # Disable audio 62 | FULLSCREEN = 1 # On for fullscreen playback 63 | 64 | 65 | class CONFIG(object): 66 | 67 | _LOG_NAME = "Config" 68 | 69 | # Filled with overwritten advanced settings for logging purpose 70 | advanced_overwritten = [] 71 | 72 | @classmethod 73 | def load(cls): 74 | """Load config file from disk""" 75 | 76 | cls.config = ConfigParser() 77 | 78 | if not os.path.isfile(constants.CONSTANTS.CONFIG_PATH): 79 | logger.log_message(cls._LOG_NAME, logger.LOGLEVEL.ERROR, 80 | "Settings file '%s' not found!" % constants.CONSTANTS.CONFIG_PATH) 81 | sys.exit("No configuration file found") 82 | 83 | # Read config file 84 | cls.config.read(constants.CONSTANTS.CONFIG_PATH) 85 | 86 | # Advanced settings, overridable in config file 87 | cls.LOG_LEVEL = cls.read_setting_default_int("ADVANCED", "loglevel", logger.LOGLEVEL.DEBUG) 88 | cls.SCREEN_WIDTH = cls.read_setting_default_int("ADVANCED", "screenwidth", 0) # 0 = Auto detect 89 | cls.SCREEN_HEIGHT = cls.read_setting_default_int("ADVANCED", "screenheight", 0) # 0 = Auto detect 90 | cls.BUFFERTIME_MS = cls.read_setting_default_int("ADVANCED", "buffertime", 500) 91 | cls.HARDWARE_CHECK = cls.read_setting_default_int("ADVANCED", "hardwarecheck", 1) 92 | cls.CHANGE_OVER = cls.read_setting_default_int("ADVANCED", "screenchangeover", CHANGEOVER.PREBUFFER) 93 | cls.SHOWTIME = cls.read_setting_default_int("ADVANCED", "showtime", 10) 94 | cls.BACKGROUND_MODE = cls.read_setting_default_int("ADVANCED", "backgroundmode", BACKGROUND.DYNAMIC) 95 | cls.ENABLE_ICONS = cls.read_setting_default_int("ADVANCED", "icons", 1) 96 | cls.STREAM_WATCHDOG_SEC = cls.read_setting_default_int("ADVANCED", "streamwatchdog", 15) 97 | cls.PLAYTIMEOUT_SEC = cls.read_setting_default_int("ADVANCED", "playtimeout", 10) 98 | cls.STREAM_QUALITY = cls.read_setting_default_int("ADVANCED", "streamquality", STREAMQUALITY.AUTO) 99 | cls.REFRESHTIME_MINUTES = cls.read_setting_default_int("ADVANCED", "refreshtime", 60) 100 | cls.HEVC_MODE = cls.read_setting_default_int("ADVANCED", "enablehevc", HEVCMODE.AUTO) 101 | cls.AUDIO_MODE = cls.read_setting_default_int("ADVANCED", "enableaudio", AUDIOMODE.OFF) 102 | cls.AUDIO_VOLUME = cls.read_setting_default_int("ADVANCED", "audiovolume", 100) # 100% 103 | cls.SCREEN_DOWNSCALE = cls.read_setting_default_int("ADVANCED", "screendownscale", 0) # 0% 104 | cls.VIDEO_OSD = cls.read_setting_default_int("ADVANCED", "enablevideoosd", 0) # Channel name overlay on video 105 | 106 | @classmethod 107 | def get_settings_for_section(cls, section): 108 | """Get all settings in a specific section of the config file""" 109 | 110 | return cls.config.items(section) 111 | 112 | @classmethod 113 | def read_setting(cls, section, setting): 114 | """Read setting from config file?""" 115 | 116 | return cls.read_setting_default(section, setting, None) 117 | 118 | @classmethod 119 | def has_setting(cls, section, setting): 120 | """Setting present in config file?""" 121 | 122 | if not (cls.config.has_section(section)): 123 | return False 124 | 125 | return cls.config.has_option(section, setting) 126 | 127 | @classmethod 128 | def has_section(cls, section): 129 | """Section present in config file?""" 130 | 131 | return cls.config.has_section(section) 132 | 133 | @classmethod 134 | def read_setting_default(cls, section, setting, default): 135 | """ 136 | Read setting from configfile. 137 | Returns default if setting not present. 138 | """ 139 | 140 | if not (cls.config.has_section(section)): 141 | return default 142 | 143 | if not (cls.config.has_option(section, str(setting))): 144 | return default 145 | 146 | value = cls.config.get(section, setting) 147 | 148 | return value 149 | 150 | @classmethod 151 | def read_setting_default_int(cls, section, setting, default): 152 | """ 153 | Read integer setting from configfile. 154 | Returns default if setting not present. 155 | """ 156 | 157 | try: 158 | config_value = int(cls.read_setting_default(section, setting, default)) 159 | 160 | # Save non default advanced settings for logging purpose 161 | if section == "ADVANCED" and config_value != default: 162 | cls.advanced_overwritten.append([setting, config_value]) 163 | 164 | return config_value 165 | 166 | except ValueError: 167 | logger.log_message(cls._LOG_NAME, logger.LOGLEVEL.ERROR, 168 | "failed to parse integer value from setting '%s', " 169 | "using the default" % setting) 170 | 171 | return default 172 | -------------------------------------------------------------------------------- /camplayer/utils/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import subprocess 4 | import re 5 | import time 6 | 7 | # Only supported revisions are listed at the moment 8 | # Non supported devices includes: 9 | # - Devices without ethernet/WLAN 10 | # - Devices older than model 2 11 | # Source: https://www.raspberrypi.org/documentation/hardware/raspberrypi/revision-codes/README.md 12 | pi_revisions = { 13 | "9000c1" : {"model": "Zero W", "supported": True, "dual_hdmi": False, "hevc": False}, 14 | "a01040" : {"model": "Zero W", "supported": True, "dual_hdmi": False, "hevc": False}, 15 | "a01041" : {"model": "2B", "supported": True, "dual_hdmi": False, "hevc": False}, 16 | "a21041" : {"model": "2B", "supported": True, "dual_hdmi": False, "hevc": False}, 17 | "a22042" : {"model": "2B", "supported": True, "dual_hdmi": False, "hevc": False}, 18 | "a02082" : {"model": "3B", "supported": True, "dual_hdmi": False, "hevc": False}, 19 | "a32082" : {"model": "3B", "supported": True, "dual_hdmi": False, "hevc": False}, 20 | "a22082" : {"model": "3B", "supported": True, "dual_hdmi": False, "hevc": False}, 21 | "a52082" : {"model": "3B", "supported": True, "dual_hdmi": False, "hevc": False}, 22 | "a22083" : {"model": "3B", "supported": True, "dual_hdmi": False, "hevc": False}, 23 | "a020d3" : {"model": "3B+", "supported": True, "dual_hdmi": False, "hevc": False}, 24 | "a03111" : {"model": "4B 1GB", "supported": True, "dual_hdmi": True, "hevc": True}, 25 | "b03111" : {"model": "4B 2GB", "supported": True, "dual_hdmi": True, "hevc": True}, 26 | "c03111" : {"model": "4B 4GB", "supported": True, "dual_hdmi": True, "hevc": True}, 27 | "b03112" : {"model": "4B 2GB", "supported": True, "dual_hdmi": True, "hevc": True}, 28 | "c03112" : {"model": "4B 4GB", "supported": True, "dual_hdmi": True, "hevc": True}, 29 | "d03114" : {"model": "4B 8GB", "supported": True, "dual_hdmi": True, "hevc": True}, 30 | "b03114" : {"model": "4B 2GB", "supported": True, "dual_hdmi": True, "hevc": True}, 31 | "c03114" : {"model": "4B 4GB", "supported": True, "dual_hdmi": True, "hevc": True}, 32 | "c03130" : {"model": "PI 400 4GB", "supported": True, "dual_hdmi": True, "hevc": True}, 33 | "9020e0" : {"model": "3A+", "supported": True, "dual_hdmi": False, "hevc": False}, 34 | } 35 | 36 | 37 | def get_gpu_memory(): 38 | """Get the amount of memory allocated to the GPU in MB""" 39 | 40 | try: 41 | response = subprocess.check_output(['vcgencmd', 'get_mem', 'gpu']).decode() 42 | 43 | if response: 44 | response = re.findall('\d+', str(response)) 45 | return int(response[0]) 46 | except: 47 | pass 48 | 49 | return 0 50 | 51 | 52 | def get_hardware_info(): 53 | """Get hardware info (SoC, HW revision, S/N, Model name)""" 54 | 55 | revision = "" 56 | serial = "" 57 | soc = "" 58 | model = "" 59 | dual_hdmi = False 60 | hevc_decoder = False 61 | supported = False 62 | 63 | try: 64 | response = subprocess.check_output( 65 | ['cat', '/proc/cpuinfo'], timeout=2).decode().splitlines() 66 | 67 | for line in response: 68 | if "revision" in line.lower(): 69 | revision = line.split(':')[1].strip() 70 | elif "serial" in line.lower(): 71 | serial = line.split(':')[1].strip() 72 | elif "hardware" in line.lower(): 73 | soc = line.split(':')[1].strip() 74 | 75 | if revision: 76 | rev_map = pi_revisions.get(revision, model) 77 | 78 | if rev_map: 79 | model = rev_map.get("model") 80 | supported = rev_map.get('supported') 81 | dual_hdmi = rev_map.get('dual_hdmi') 82 | hevc_decoder = rev_map.get('hevc') 83 | except: 84 | pass 85 | 86 | return {'soc': soc, 'revision': revision, 'serial': serial, 'hevc': hevc_decoder, 87 | 'model': model, 'supported': supported, 'dual_hdmi': dual_hdmi} 88 | 89 | 90 | def get_system_info(): 91 | """Get a description of this operation system""" 92 | 93 | try: 94 | return str(subprocess.check_output( 95 | ['uname', '-a'], universal_newlines=True)).splitlines()[0] 96 | except: 97 | pass 98 | 99 | return "" 100 | 101 | 102 | def kill_service(service, force=False): 103 | """Terminate all processes with a given name""" 104 | 105 | try: 106 | subprocess.Popen(['killall', '-15', service], shell=False, 107 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).wait(timeout=2) 108 | except: 109 | pass 110 | 111 | if force: 112 | time.sleep(0.5) 113 | try: 114 | subprocess.Popen(['killall', '-9', service], shell=False, 115 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).wait(timeout=2) 116 | except: 117 | pass 118 | 119 | 120 | def terminate_process(PID, force=False): 121 | """Terminate a process by its""" 122 | 123 | try: 124 | subprocess.Popen(['kill', '-15', str(PID)], shell=False, 125 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).wait(timeout=2) 126 | except: 127 | pass 128 | 129 | if force: 130 | time.sleep(0.5) 131 | try: 132 | subprocess.Popen(['kill', '-9', str(PID)], shell=False, 133 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).wait(timeout=2) 134 | except: 135 | pass 136 | 137 | 138 | def get_display_mode(display=2): 139 | """Get current diplay mode (display 2 = HDMI0, display 7 = HDMI1)""" 140 | 141 | hdmi_group = 'Unknown' 142 | hdmi_mode = 0 143 | res_width = 0 144 | res_height = 0 145 | framerate = 0 146 | device_name = "" 147 | 148 | try: 149 | response = subprocess.check_output( 150 | ['tvservice', '--device', str(display), '--status'], 151 | stderr=subprocess.STDOUT).decode().splitlines()[0] 152 | 153 | tmp = re.search('^state.+(DMT|CEA).*\((\d+)\)[\s*\S*]* (\d+)x(\d+).+@ (\d+)', response) 154 | if tmp: 155 | hdmi_group = tmp.group(1) 156 | hdmi_mode = int(tmp.group(2)) 157 | res_width = int(tmp.group(3)) 158 | res_height = int(tmp.group(4)) 159 | framerate = int(tmp.group(5)) 160 | 161 | response = subprocess.check_output( 162 | ['tvservice', '--device', str(display), '--name'], 163 | timeout=2, stderr=subprocess.STDOUT).decode() 164 | 165 | if "device_name=" in response: 166 | device_name = response.split('=')[1].strip() 167 | except: 168 | pass 169 | 170 | return {'hdmi_group': hdmi_group, 'hdmi_mode': hdmi_mode, 'res_width': res_width, 171 | 'res_height': res_height, 'framerate': framerate, 'device_name': device_name} 172 | 173 | 174 | def os_package_installed(package): 175 | """Check if some linux package/application is installed""" 176 | 177 | try: 178 | subprocess.check_output(['which', package], 179 | stderr=subprocess.STDOUT).decode().splitlines()[0] 180 | 181 | return True 182 | 183 | except: 184 | pass 185 | 186 | return False 187 | -------------------------------------------------------------------------------- /camplayer/windowmanager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import subprocess 4 | import os 5 | import time 6 | import signal 7 | import threading 8 | import sys 9 | import math 10 | 11 | from enum import IntEnum 12 | from enum import unique 13 | 14 | from utils import utils 15 | from utils.logger import LOG 16 | from utils.settings import CONFIG, STREAMQUALITY, AUDIOMODE 17 | from utils.constants import CONSTANTS 18 | from utils.globals import GLOBALS 19 | from streaminfo import StreamInfo 20 | 21 | 22 | @unique 23 | class PLAYSTATE(IntEnum): 24 | NONE = 0 # Nothing is playing 25 | INIT1 = 1 # Player starting 26 | INIT2 = 2 # Player started (PID assigned) but video still loading/buffering 27 | PLAYING = 3 # Player stated and video playing 28 | BROKEN = 4 # Player video stream broken 29 | 30 | @unique 31 | class PLAYER(IntEnum): 32 | NONE = 0 33 | OMXPLAYER = 1 34 | VLCPLAYER = 2 35 | 36 | 37 | class DBUS_COMMAND(object): 38 | PLAY_STATUS = "PlaybackStatus" 39 | PLAY_DURATION = "Duration" 40 | PLAY_POSITION = "Position" 41 | PLAY_STOP = "Stop" 42 | PLAY_PLAY = "OpenUri" 43 | PLAY_VOLUME = "Volume" 44 | OMXPLAYER_VIDEOPOS = "VideoPos" 45 | OMXPLAYER_LAYER = "SetLayer" 46 | 47 | 48 | class Window(object): 49 | 50 | # TODO Camplayer 2 51 | # * Drop OMXplayer support and switch completely to VLC 52 | # * Add windowed playback support for VLC 53 | # * Drop all hacks introduced to support both players 54 | # * Refactor audio support 55 | 56 | _LOG_NAME = "Window" 57 | 58 | # Holds all player PIDs and 59 | # associated command line arguments 60 | _player_pid_pool_cmdline = [[], []] 61 | 62 | # VLC is currently only supported for fullscreen playback 63 | # so only one instance can exist for each display 64 | _vlc_dbus_ident = ["" for _ in range(GLOBALS.NUM_DISPLAYS)] 65 | _vlc_active_stream_url = ["" for _ in range(GLOBALS.NUM_DISPLAYS)] 66 | _vlc_subs_enabled = [False for _ in range(GLOBALS.NUM_DISPLAYS)] 67 | vlc_player_pid = [0 for _ in range(GLOBALS.NUM_DISPLAYS)] 68 | 69 | # Active estimated decoder weight for all windows 70 | _total_weight = 0 71 | 72 | def __init__(self, x1, y1, x2, y2, gridindex, screen_idx, window_idx, display_idx): 73 | 74 | self.x1 = x1 # Upper left x-position of window 75 | self.y1 = y1 # Upper left y-position of window 76 | self.x2 = x2 # Lower right x-position of window 77 | self.y2 = y2 # Lower right y-position of window 78 | self.gridindex = gridindex # Grid indices covered by this window 79 | self.omx_player_pid = 0 # OMXplayer PID 80 | self._omx_audio_enabled = False # OMXplayer audio stream enabled 81 | self._omx_duration = 0 # OMXplayer reported stream duration 82 | self._layer = 0 # Player dispmanx layer 83 | self.visible = False # Is window in visible area? 84 | self._forced_fullscreen = self.native_fullscreen # Is window forced in fullscreen mode? 85 | self._fail_rate_hr = 0 # Stream failure rate of last hour 86 | self._time_playstatus = 0 # Timestamp of last playstatus check 87 | self._time_streamstart = 0 # Timestamp of last stream start 88 | self.streams = [] # Assigned stream(s) 89 | self.active_stream = None # Currently playing stream 90 | self._display_name = "" # Video OSD display name 91 | self._player = PLAYER.NONE # Currently active player for this window (OMX or VLC) 92 | self.playstate = PLAYSTATE.NONE # Current stream play state for this window 93 | self._window_num = window_idx + 1 94 | self._screen_num = screen_idx + 1 95 | self._display_num = display_idx + 1 96 | self.force_udp = False 97 | 98 | self._omx_dbus_ident = str("org.mpris.MediaPlayer2.omxplayer_D%02d_S%02d_W%02d" % 99 | (self._display_num, self._screen_num, self._window_num)) 100 | 101 | LOG.DEBUG(self._LOG_NAME, 102 | "init window with position '%i %i %i %i', gridindex '%s', " 103 | "omxplayer dbus name '%s'" 104 | % (x1, y1, x2, y2, str(gridindex), self._omx_dbus_ident)) 105 | 106 | def add_stream(self, url): 107 | """Add a stream URL to this window""" 108 | 109 | if not url: 110 | return 111 | 112 | self.streams.append(StreamInfo(url)) 113 | 114 | def set_display_name(self, display_name): 115 | """Set player OSD text for this window""" 116 | 117 | if not display_name or self._display_name: 118 | return 119 | 120 | sub_file = CONSTANTS.CACHE_DIR + display_name + ".srt" 121 | 122 | try: 123 | # Create folder if not exist 124 | if not os.path.isdir(os.path.dirname(sub_file)): 125 | os.system("mkdir -p %s" % os.path.dirname(sub_file)) 126 | 127 | # Create subtitle file if not exist 128 | if not os.path.isfile(sub_file): 129 | with open(sub_file, 'w+') as file: 130 | # Important note: we can only show subs for a 99 hour period! 131 | file.write('00:00:00,00 --> 99:00:00,00\n') 132 | file.write(display_name + '\n') 133 | 134 | self._display_name = display_name 135 | except: 136 | 137 | # TODO: filter for read-only error only 138 | LOG.ERROR(self._LOG_NAME, "writing subtitle file failed, read only?") 139 | 140 | @property 141 | def native_fullscreen(self): 142 | """Is this window the same size as the configured screen?""" 143 | 144 | return self.x1 == CONSTANTS.VIRT_SCREEN_OFFSET_X and \ 145 | self.y1 == CONSTANTS.VIRT_SCREEN_OFFSET_Y and \ 146 | self.x2 == CONSTANTS.VIRT_SCREEN_OFFSET_X + CONSTANTS.VIRT_SCREEN_WIDTH and \ 147 | self.y2 == CONSTANTS.VIRT_SCREEN_OFFSET_Y + CONSTANTS.VIRT_SCREEN_HEIGHT 148 | 149 | @property 150 | def fullscreen_mode(self): 151 | """Is this window the same size as the configured screen?""" 152 | 153 | return self.native_fullscreen or self._forced_fullscreen 154 | 155 | @fullscreen_mode.setter 156 | def fullscreen_mode(self, value): 157 | self._forced_fullscreen = value 158 | 159 | @property 160 | def playtime(self): 161 | """Get playtime in seconds""" 162 | 163 | return time.monotonic() - self._time_streamstart 164 | 165 | @property 166 | def window_width(self): 167 | """Get the window width""" 168 | 169 | return int(self.x2 - self.x1) 170 | 171 | @property 172 | def window_height(self): 173 | """Get the window height""" 174 | 175 | return int(self.y2 - self.y1) 176 | 177 | def get_weight(self, stream=None): 178 | """Get decoder weight for the current/default stream""" 179 | 180 | if stream: 181 | pass 182 | elif self.active_stream: 183 | stream = self.active_stream 184 | else: 185 | stream = self.get_default_stream() 186 | 187 | if stream: 188 | return stream.weight 189 | 190 | return 0 191 | 192 | def get_lowest_quality_stream(self, windowed=None): 193 | """Get the lowest quality stream/subchannel""" 194 | 195 | if len(self.streams) <= 0: 196 | return None 197 | 198 | if self.native_fullscreen: 199 | windowed = False 200 | elif windowed is None: 201 | windowed = not self.fullscreen_mode 202 | 203 | stream = None 204 | 205 | quality = sys.maxsize 206 | 207 | # Select the lowest valid resolution stream by default 208 | for strm in self.streams: 209 | 210 | video_valid = strm.valid_video_fullscreen \ 211 | if not windowed else strm.valid_video_windowed 212 | 213 | if quality > strm.quality > 10000 and video_valid: 214 | quality = strm.quality 215 | stream = strm 216 | 217 | return stream 218 | 219 | def get_highest_quality_stream(self, prevent_downscaling=False, windowed=None): 220 | """Get the highest quality stream/subchannel""" 221 | 222 | if len(self.streams) <= 0: 223 | return None 224 | 225 | if self.native_fullscreen: 226 | windowed = False 227 | elif windowed is None: 228 | windowed = not self.fullscreen_mode 229 | 230 | stream = None 231 | 232 | window_width = CONSTANTS.VIRT_SCREEN_WIDTH if not windowed else self.window_width 233 | window_height = CONSTANTS.VIRT_SCREEN_HEIGHT if not windowed else self.window_height 234 | 235 | # Select the highest valid resolution 236 | for strm in self.streams: 237 | 238 | video_valid = strm.valid_video_fullscreen \ 239 | if not windowed else strm.valid_video_windowed 240 | 241 | if strm.quality > 10000 and video_valid: 242 | 243 | if not stream: 244 | stream = strm 245 | 246 | # Downscaling is costly (GPU), much more than upscaling... 247 | if prevent_downscaling: 248 | 249 | if strm.height > stream.height and strm.height <= window_height: 250 | stream = strm 251 | 252 | elif strm.height < stream.height and stream.height > window_height: 253 | stream = strm 254 | 255 | else: 256 | 257 | if strm.height > stream.height and stream.height < window_height: 258 | stream = strm 259 | 260 | # It makes no sense to select a too large resolution. 261 | # e.g. We have two streams, one 480p and one 1080p. 262 | # Let's assume our playback window is 360 pixels high, 263 | # then we want to select the 480p stream instead of the 1080p one. 264 | elif strm.height < stream.height and strm.height >= window_height: 265 | stream = strm 266 | 267 | return stream 268 | 269 | def get_default_stream(self, windowed=None): 270 | """Get the default stream based on the used configuration""" 271 | 272 | if len(self.streams) <= 0: 273 | return None 274 | 275 | if self.native_fullscreen: 276 | windowed = False 277 | elif windowed is None: 278 | windowed = not self.fullscreen_mode 279 | 280 | stream = None 281 | 282 | if CONFIG.STREAM_QUALITY == STREAMQUALITY.LOW: 283 | stream = self.get_lowest_quality_stream(windowed=windowed) 284 | 285 | elif CONFIG.STREAM_QUALITY == STREAMQUALITY.HIGH: 286 | stream = self.get_highest_quality_stream(windowed=windowed) 287 | 288 | elif CONFIG.STREAM_QUALITY == STREAMQUALITY.AUTO: 289 | 290 | # Downscaling is costly (GPU), much more than upscaling... 291 | # A perfect resolution match is the best. 292 | stream = self.get_highest_quality_stream(prevent_downscaling=True, windowed=windowed) 293 | 294 | return stream 295 | 296 | def stream_set_visible(self, _async=False, fullscreen=None): 297 | """Set an active off screen stream back on screen""" 298 | 299 | if self._player == PLAYER.VLCPLAYER \ 300 | and not self.get_vlc_pid(self._display_num): 301 | return 302 | 303 | if self.playstate == PLAYSTATE.NONE: 304 | return 305 | 306 | if fullscreen is None: 307 | fullscreen = self.fullscreen_mode 308 | 309 | if not self.visible or (fullscreen != self.fullscreen_mode): 310 | 311 | LOG.INFO(self._LOG_NAME, "stream set visible '%s' '%s'" % 312 | (self._omx_dbus_ident, self.active_stream.printable_url())) 313 | 314 | self.fullscreen_mode = fullscreen 315 | 316 | if self._player == PLAYER.OMXPLAYER: 317 | # OMXplayer instance is playing outside the visible screen area. 318 | # Sending the position command will move this instance into the visible screen area. 319 | 320 | if fullscreen: 321 | videopos_arg = str("%i %i %i %i" % (CONSTANTS.VIRT_SCREEN_OFFSET_X, CONSTANTS.VIRT_SCREEN_OFFSET_Y, 322 | CONSTANTS.VIRT_SCREEN_OFFSET_X + CONSTANTS.VIRT_SCREEN_WIDTH, 323 | CONSTANTS.VIRT_SCREEN_OFFSET_Y + CONSTANTS.VIRT_SCREEN_HEIGHT)) 324 | else: 325 | videopos_arg = str("%i %i %i %i" % (self.x1, self.y1, self.x2, self.y2)) 326 | 327 | # Re-open OMXplayer with the audio stream enabled 328 | if CONFIG.AUDIO_MODE == AUDIOMODE.FULLSCREEN and fullscreen \ 329 | and not self._omx_audio_enabled and self.active_stream.has_audio: 330 | self.visible = True 331 | self.stream_refresh() 332 | return 333 | 334 | # Re-open OMXplayer with the audio stream disabled 335 | if self._omx_audio_enabled and not fullscreen: 336 | self.visible = True 337 | self.stream_refresh() 338 | return 339 | 340 | if _async: 341 | setvisible_thread = threading.Thread( 342 | target=self._send_dbus_command, 343 | args=(DBUS_COMMAND.OMXPLAYER_VIDEOPOS, videopos_arg,)) 344 | 345 | setvisible_thread.start() 346 | else: 347 | self._send_dbus_command(DBUS_COMMAND.OMXPLAYER_VIDEOPOS, videopos_arg) 348 | 349 | elif fullscreen: 350 | # VLC player instance can be playing or in idle state. 351 | # Sending the play command will start fullscreen playback of our video/stream. 352 | # When VLC is playing other content, we will hijack it. 353 | 354 | # Start our stream 355 | self._send_dbus_command(DBUS_COMMAND.PLAY_PLAY) 356 | 357 | # Pretend like the player just started again 358 | self.playstate = PLAYSTATE.INIT2 359 | self._time_streamstart = time.monotonic() 360 | 361 | # Mark our steam as the active one for this display 362 | Window._vlc_active_stream_url[self._display_num - 1] = self.active_stream.url 363 | 364 | else: 365 | # Windowed with VLC not supported -> stop video 366 | self.stream_stop() 367 | 368 | self.visible = True 369 | 370 | def stream_set_invisible(self, _async=False): 371 | """Keep the stream open but set it off screen""" 372 | 373 | if self.playstate == PLAYSTATE.NONE: 374 | return 375 | 376 | if self.visible: 377 | LOG.INFO(self._LOG_NAME, "stream set invisible '%s' '%s'" % 378 | (self._omx_dbus_ident, self.active_stream.printable_url())) 379 | 380 | if self._player == PLAYER.OMXPLAYER: 381 | # OMXplayer instance is playing inside the visible screen area. 382 | # Sending the position command with offset will move this instance out of the visible screen area. 383 | 384 | if self._omx_audio_enabled: 385 | self.visible = False 386 | self.stream_refresh() 387 | return 388 | 389 | videopos_arg = str("%i %i %i %i" % ( 390 | self.x1 + CONSTANTS.WINDOW_OFFSET, self.y1, 391 | self.x2 + CONSTANTS.WINDOW_OFFSET, self.y2)) 392 | 393 | if _async: 394 | setinvisible_thread = threading.Thread( 395 | target=self._send_dbus_command, 396 | args=(DBUS_COMMAND.OMXPLAYER_VIDEOPOS, videopos_arg,)) 397 | 398 | setinvisible_thread.start() 399 | else: 400 | self._send_dbus_command(DBUS_COMMAND.OMXPLAYER_VIDEOPOS, videopos_arg) 401 | 402 | else: 403 | 404 | # It's possible that another window hijacked our vlc instance, so do not send 'stop' then. 405 | if self.active_stream.url == Window._vlc_active_stream_url[self._display_num - 1]: 406 | self._send_dbus_command(DBUS_COMMAND.PLAY_STOP) 407 | Window._vlc_active_stream_url[self._display_num - 1] = "" 408 | 409 | self.visible = False 410 | 411 | def get_stream_playstate(self): 412 | """ 413 | Get and update the stream's playstate, 414 | don't use this time consuming method too often, 415 | use 'self.playstate' when you can. 416 | """ 417 | 418 | if self.playstate == PLAYSTATE.NONE: 419 | return self.playstate 420 | 421 | # Allow at least 1 second for the player to startup 422 | if self.playstate == PLAYSTATE.INIT1 and self.playtime < 1: 423 | return self.playstate 424 | 425 | old_playstate = self.playstate 426 | 427 | # Assign the player PID 428 | if self.playstate == PLAYSTATE.INIT1: 429 | 430 | if self._player == PLAYER.VLCPLAYER: 431 | pid = self.get_vlc_pid(self._display_num) 432 | else: 433 | pid = self.get_omxplayer_pid() 434 | 435 | if pid > 0: 436 | if self._player == PLAYER.VLCPLAYER: 437 | Window.vlc_player_pid[self._display_num - 1] = pid 438 | else: 439 | self.omx_player_pid = pid 440 | 441 | self.playstate = PLAYSTATE.INIT2 442 | 443 | LOG.DEBUG(self._LOG_NAME, "assigned PID '%i' for stream '%s' '%s'" % 444 | (pid, self._omx_dbus_ident, self.active_stream.printable_url())) 445 | 446 | elif self.playtime > CONSTANTS.PLAYER_INITIALIZE_MS / 1000: 447 | self.playstate = PLAYSTATE.BROKEN 448 | 449 | # Check if the player is actually playing media 450 | # DBus calls are time consuming, so limit them 451 | elif time.monotonic() > (self._time_playstatus + 10) or \ 452 | (self.playstate == PLAYSTATE.INIT2 and time.monotonic() > (self._time_playstatus + 1)): 453 | 454 | LOG.DEBUG(self._LOG_NAME, "fetching playstate for stream '%s' '%s'" % 455 | (self._omx_dbus_ident, self.active_stream.printable_url())) 456 | 457 | duration_diff = 0 458 | output = "" 459 | 460 | # Check playstate and kill the player if it does not respond properly 461 | # 04/04/2020: Under some circumstances omxplayer freezes with corrupt streams (bad wifi/network quality etc.), 462 | # while it still reports its playstate as 'playing', 463 | # therefore we monitor will monitor the reported 'duration' (for livestreams) from now on. 464 | if not self.active_stream.url.startswith('file://') and self._player == PLAYER.OMXPLAYER: 465 | 466 | output = self._send_dbus_command( 467 | DBUS_COMMAND.PLAY_DURATION, kill_player_on_error=self.playtime > CONFIG.PLAYTIMEOUT_SEC) 468 | 469 | try: 470 | duration = int(output.split("int64")[1].strip()) 471 | duration_diff = duration - self._omx_duration 472 | self._omx_duration = duration 473 | except Exception: 474 | self._omx_duration = 0 475 | 476 | else: 477 | output = self._send_dbus_command( 478 | DBUS_COMMAND.PLAY_STATUS, kill_player_on_error=self.playtime > CONFIG.PLAYTIMEOUT_SEC) 479 | 480 | if (output and "playing" in str(output).lower()) or duration_diff > 0: 481 | self.playstate = PLAYSTATE.PLAYING 482 | 483 | else: 484 | # Only set broken after a timeout period, 485 | # so keep the init state the first seconds 486 | if self.playtime > CONFIG.PLAYTIMEOUT_SEC: 487 | 488 | if self._player == PLAYER.OMXPLAYER or self.visible: 489 | # Don't set broken when VLC is in "stopped" state 490 | # Stopped state occurs when not visible 491 | 492 | self.playstate = PLAYSTATE.BROKEN 493 | 494 | self._time_playstatus = time.monotonic() 495 | 496 | if old_playstate != self.playstate: 497 | LOG.INFO(self._LOG_NAME, "stream playstate '%s' for stream '%s' '%s'" % 498 | (self.playstate.name, self._omx_dbus_ident, self.active_stream.printable_url())) 499 | 500 | return self.playstate 501 | 502 | def _send_dbus_command(self, command, argument="", kill_player_on_error=True, retries=CONSTANTS.DBUS_RETRIES): 503 | """Send command to player with DBus""" 504 | 505 | response = "" 506 | command_destination = "" 507 | command_prefix = "" 508 | 509 | if self._player == PLAYER.OMXPLAYER: 510 | command_destination = self._omx_dbus_ident 511 | 512 | # OMXplayer needs some environment variables 513 | command_prefix = str("export DBUS_SESSION_BUS_ADDRESS=`cat /tmp/omxplayerdbus.%s` && " 514 | "export DBUS_SESSION_BUS_PID=`cat /tmp/omxplayerdbus.%s.pid` && " 515 | % (GLOBALS.USERNAME, GLOBALS.USERNAME)) 516 | 517 | elif self._player == PLAYER.VLCPLAYER: 518 | command_destination = Window._vlc_dbus_ident[self._display_num - 1] 519 | 520 | # VLC changes its DBus string to 'org.mpris.MediaPlayer2.vlc.instancePID' 521 | # when opening a second instance, so we have to append this PID first. 522 | if 'instance' in command_destination: 523 | command_destination += str(Window.vlc_player_pid[self._display_num - 1]) 524 | 525 | for i in range(retries + 1): 526 | try: 527 | if command == DBUS_COMMAND.OMXPLAYER_VIDEOPOS: 528 | response = subprocess.check_output( 529 | command_prefix + "dbus-send --print-reply=literal --reply-timeout=%i " 530 | "--dest=%s /org/mpris/MediaPlayer2 " 531 | "org.mpris.MediaPlayer2.Player.%s objpath:/not/used " 532 | "string:'%s'" % (CONSTANTS.DBUS_TIMEOUT_MS, command_destination, 533 | command, argument), shell=True, stderr=subprocess.STDOUT).decode().strip() 534 | 535 | elif command == DBUS_COMMAND.PLAY_STOP: 536 | response = subprocess.check_output( 537 | command_prefix + "dbus-send --print-reply=literal --reply-timeout=%i " 538 | "--dest=%s /org/mpris/MediaPlayer2 " 539 | "org.mpris.MediaPlayer2.Player.%s" % 540 | (CONSTANTS.DBUS_TIMEOUT_MS, command_destination, command), 541 | shell=True, stderr=subprocess.STDOUT).decode().strip() 542 | 543 | elif command == DBUS_COMMAND.PLAY_PLAY: 544 | response = subprocess.check_output( 545 | command_prefix + "dbus-send --print-reply=literal --reply-timeout=%i " 546 | "--dest=%s /org/mpris/MediaPlayer2 " 547 | "org.mpris.MediaPlayer2.Player.%s string:'%s'" % 548 | (CONSTANTS.DBUS_TIMEOUT_MS, command_destination, command, 549 | self.active_stream.url), 550 | shell=True, stderr=subprocess.STDOUT).decode().strip() 551 | 552 | elif command == DBUS_COMMAND.PLAY_VOLUME: 553 | response=subprocess.check_output( 554 | command_prefix + "dbus-send --print-reply=literal --reply-timeout=%i " 555 | "--dest=%s /org/mpris/MediaPlayer2 " 556 | "org.freedesktop.DBus.Properties.Set " 557 | "string:'org.mpris.MediaPlayer2.Player' string:'%s' variant:double:%f" % 558 | (CONSTANTS.DBUS_TIMEOUT_MS, command_destination, command, argument), 559 | shell=True, stderr=subprocess.STDOUT).decode().strip() 560 | 561 | else: 562 | response = subprocess.check_output( 563 | command_prefix + "dbus-send --print-reply=literal --reply-timeout=%i " 564 | "--dest=%s /org/mpris/MediaPlayer2 " 565 | "org.freedesktop.DBus.Properties.Get " 566 | "string:'org.mpris.MediaPlayer2.Player' string:'%s'" % 567 | (CONSTANTS.DBUS_TIMEOUT_MS, command_destination, command), 568 | shell=True, stderr=subprocess.STDOUT).decode().strip() 569 | 570 | LOG.DEBUG(self._LOG_NAME, "DBus response to command '%s:%s %s' is '%s'" % 571 | (command_destination, command, argument, response)) 572 | 573 | except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as ex: 574 | 575 | if i == retries: 576 | 577 | if self._player == PLAYER.VLCPLAYER: 578 | player_pid = Window.vlc_player_pid[self._display_num - 1] 579 | else: 580 | player_pid = self.omx_player_pid 581 | 582 | LOG.ERROR(self._LOG_NAME, "DBus '%s' is not responding correctly after '%i' attemps, " 583 | "give up now" % (command_destination, retries + 1)) 584 | 585 | if kill_player_on_error and player_pid > 0: 586 | LOG.ERROR(self._LOG_NAME, "DBus '%s' closing the associated player " 587 | "with PID '%i' now" % (command_destination, player_pid)) 588 | 589 | try: 590 | os.kill(player_pid, signal.SIGKILL) 591 | except ProcessLookupError: 592 | LOG.DEBUG(self._LOG_NAME, "killing PID '%i' failed" % player_pid) 593 | 594 | self._pidpool_remove_pid(player_pid) 595 | 596 | if self._player == PLAYER.VLCPLAYER: 597 | Window.vlc_player_pid[self._display_num - 1] = 0 598 | else: 599 | self.omx_player_pid = 0 600 | 601 | else: 602 | LOG.WARNING(self._LOG_NAME, "DBus '%s' is not responding correctly, " 603 | "retrying within 250ms" % command_destination) 604 | time.sleep(0.25) 605 | continue 606 | 607 | break 608 | 609 | return response 610 | 611 | def stream_switch_quality_up(self, check_only=False, limit_default=True): 612 | """Switch to the next higher quality stream, if any""" 613 | 614 | if self.active_stream and self.playstate != PLAYSTATE.NONE: 615 | 616 | resolution = sys.maxsize 617 | stream = None 618 | 619 | # Limit max quality to the default for performance reasons 620 | if limit_default: 621 | resolution = self.get_default_stream(windowed=not self.fullscreen_mode).quality + 1 622 | 623 | # Select the the next higher resolution stream 624 | for strm in self.streams: 625 | 626 | video_valid = strm.valid_video_fullscreen \ 627 | if self.fullscreen_mode else strm.valid_video_windowed 628 | 629 | if resolution > strm.quality > self.active_stream.quality and video_valid: 630 | resolution = strm.quality 631 | stream = strm 632 | 633 | # The highest quality stream is already playing 634 | if not stream: 635 | LOG.INFO(self._LOG_NAME, "highest quality stream already playing") 636 | return False 637 | 638 | if not check_only: 639 | self.stream_stop() 640 | time.sleep(0.1) 641 | self._stream_start(stream) 642 | return stream 643 | 644 | return False 645 | 646 | def stream_switch_quality_down(self, check_only=False): 647 | """Switch to the next lower quality stream, if any""" 648 | 649 | if self.active_stream and self.playstate != PLAYSTATE.NONE: 650 | 651 | resolution = 10000 652 | stream = None 653 | 654 | # Select the the next lower resolution stream 655 | for strm in self.streams: 656 | 657 | video_valid = strm.valid_video_fullscreen \ 658 | if self.fullscreen_mode else strm.valid_video_windowed 659 | 660 | if resolution < strm.quality < self.active_stream.quality and video_valid: 661 | resolution = strm.quality 662 | stream = strm 663 | 664 | # The lowest quality stream is already playing 665 | if not stream: 666 | LOG.INFO(self._LOG_NAME, "lowest quality stream already playing") 667 | return False 668 | 669 | if not check_only: 670 | self.stream_stop() 671 | time.sleep(0.1) 672 | self._stream_start(stream) 673 | 674 | return stream 675 | 676 | return False 677 | 678 | def stream_refresh(self): 679 | """Refresh/restart the current stream with the same parameters""" 680 | 681 | if self.playstate == PLAYSTATE.NONE: 682 | return 683 | 684 | stream = self.active_stream 685 | self.stream_stop() 686 | self._stream_start(stream=stream) 687 | 688 | def stream_stop(self): 689 | """Stop the playing stream""" 690 | 691 | if self.playstate == PLAYSTATE.NONE: 692 | return 693 | 694 | LOG.INFO(self._LOG_NAME, "stopping stream '%s' '%s'" % (self._omx_dbus_ident, self.active_stream.printable_url())) 695 | 696 | # VLC: 697 | # - send Dbus stop command, vlc stays idle in the background 698 | # - not every window has it's own vlc instance as vlc can only be used for fullscreen playback, 699 | # therefore we have to be sure that 'our instance' isn't already playing another stream. 700 | if self._player == PLAYER.VLCPLAYER and \ 701 | Window._vlc_active_stream_url[self._display_num - 1] == self.active_stream.url: 702 | 703 | # Stop playback but do not quit 704 | self._send_dbus_command(DBUS_COMMAND.PLAY_STOP) 705 | 706 | Window._vlc_active_stream_url[self._display_num - 1] = "" 707 | 708 | # OMXplayer: 709 | # - omxplayer doen't support an idle state, stopping playback will close omxplayer, 710 | # so in this case we also have to cleanup the pids. 711 | elif self._player == PLAYER.OMXPLAYER and self.omx_player_pid: 712 | try: 713 | os.kill(self.omx_player_pid, signal.SIGTERM) 714 | except Exception as error: 715 | LOG.ERROR(self._LOG_NAME, "pid kill error: %s" % str(error)) 716 | 717 | self._pidpool_remove_pid(self.omx_player_pid) 718 | self.omx_player_pid = 0 719 | 720 | if self.active_stream: 721 | Window._total_weight -= self.get_weight(self.active_stream) 722 | 723 | self.active_stream = None 724 | self.playstate = PLAYSTATE.NONE 725 | self._omx_duration = 0 726 | 727 | def stream_start(self, visible=None, force_fullscreen=False, force_hq=False): 728 | """Start streaming in or outside the visible screen""" 729 | 730 | stream = None 731 | 732 | if self.playstate != PLAYSTATE.NONE: 733 | return 734 | 735 | if visible is not None: 736 | self.visible = visible 737 | 738 | self.fullscreen_mode = force_fullscreen 739 | 740 | if force_hq: 741 | stream = self.get_highest_quality_stream() 742 | 743 | self._stream_start(stream=stream) 744 | 745 | def _stream_start(self, stream=None): 746 | """Start the specified stream if any, else the default will be played""" 747 | 748 | if self.playstate != PLAYSTATE.NONE: 749 | return 750 | 751 | if len(self.streams) <= 0: 752 | return 753 | 754 | if not stream: 755 | stream = self.get_default_stream() 756 | 757 | if not stream: 758 | return 759 | 760 | win_width = CONSTANTS.VIRT_SCREEN_WIDTH if self.fullscreen_mode else self.window_width 761 | win_height = CONSTANTS.VIRT_SCREEN_HEIGHT if self.fullscreen_mode else self.window_height 762 | sub_file = "" 763 | 764 | if self._display_name and CONFIG.VIDEO_OSD: 765 | sub_file = CONSTANTS.CACHE_DIR + self._display_name + ".srt" 766 | 767 | LOG.INFO(self._LOG_NAME, "starting stream '%s' '%s' with resolution '%ix%i' and weight '%i' in a window '%ix%i'" 768 | % (self._omx_dbus_ident, stream.printable_url(), stream.width, 769 | stream.height, self.get_weight(stream), win_width, win_height)) 770 | 771 | # OMXplayer can play in fullscreen and windowed mode 772 | # One instance per window 773 | if stream.valid_video_windowed: 774 | self._player = PLAYER.OMXPLAYER 775 | 776 | # Layer should be unique to avoid visual glitches/collisions 777 | omx_layer_arg = (self._screen_num * CONSTANTS.MAX_WINDOWS) + self._window_num 778 | 779 | if self.fullscreen_mode and self.visible: 780 | # Window position also required for fullscreen playback, 781 | # otherwise lower layers will be disabled when moving the window position later on 782 | 783 | omx_pos_arg = str("%i %i %i %i" % ( 784 | CONSTANTS.VIRT_SCREEN_OFFSET_X, CONSTANTS.VIRT_SCREEN_OFFSET_Y, 785 | CONSTANTS.VIRT_SCREEN_OFFSET_X + CONSTANTS.VIRT_SCREEN_WIDTH, 786 | CONSTANTS.VIRT_SCREEN_OFFSET_Y + CONSTANTS.VIRT_SCREEN_HEIGHT)) 787 | 788 | else: 789 | omx_pos_arg = str("%i %i %i %i" % ( 790 | self.x1 + (0 if self.visible else CONSTANTS.WINDOW_OFFSET), self.y1, 791 | self.x2 + (0 if self.visible else CONSTANTS.WINDOW_OFFSET), self.y2 792 | )) 793 | 794 | player_cmd = ['omxplayer', 795 | '--no-keys', # No keyboard input 796 | '--no-osd', # No OSD 797 | '--aspect-mode', 'stretch', # Stretch video if aspect doesn't match 798 | '--dbus_name', self._omx_dbus_ident, # Dbus name for controlling position etc. 799 | '--threshold', str(CONFIG.BUFFERTIME_MS / 1000), # Threshold of buffer in seconds 800 | '--layer', str(omx_layer_arg), # Dispmanx layer 801 | '--alpha', '255', # No transparency 802 | '--nodeinterlace', # Assume progressive streams 803 | '--nohdmiclocksync', # Clock sync makes no sense with multiple clock sources 804 | '--display', '7' if self._display_num == 2 else '2', # 2 is HDMI0 (default), 7 is HDMI1 (pi4) 805 | '--timeout', str(CONFIG.PLAYTIMEOUT_SEC), # Give up playback after this period of trying 806 | '--win', omx_pos_arg # Window position 807 | ] 808 | 809 | if not self.force_udp and not stream.force_udp: 810 | player_cmd.extend(['--avdict', 'rtsp_transport:tcp']) # Force RTSP over TCP 811 | 812 | if stream.url.startswith('file://'): 813 | player_cmd.append('--loop') # Loop for local files (demo/test mode) 814 | else: 815 | player_cmd.append('--live') # Avoid sync issues with long playing streams 816 | 817 | if CONFIG.AUDIO_MODE == AUDIOMODE.FULLSCREEN and \ 818 | self.visible and self.fullscreen_mode and stream.has_audio: 819 | # OMXplayer can only open 8 instances instead of 16 when audio is enabled, 820 | # this can also lead to total system lockups... 821 | # Work around this by disabling the audio stream when in windowed mode, 822 | # in fullscreen mode, we can safely enable audio again. 823 | # set_visible() and set_invisible() methods are also adopted for this. 824 | 825 | # Volume % to millibels conversion 826 | volume = int(2000 * math.log10(max(CONFIG.AUDIO_VOLUME, 0.001) / 100)) 827 | player_cmd.extend(['--vol', str(volume)]) # Set audio volume 828 | 829 | self._omx_audio_enabled = True 830 | else: 831 | player_cmd.extend(['--aidx', '-1']) # Disable audio stream 832 | self._omx_audio_enabled = False 833 | 834 | # Show our channel name with a custom subtitle file? 835 | # OMXplayer OSD not supported on pi4 hardware 836 | if sub_file and not "4B" in GLOBALS.PI_MODEL: 837 | if os.path.isfile(sub_file): 838 | player_cmd.extend(['--subtitles', sub_file ]) # Add channel name as subtitle 839 | player_cmd.extend( 840 | ['--no-ghost-box', '--align', 'center', 841 | '--lines', '1']) # Set subtitle properties 842 | 843 | # VLC media player can play only in fullscreen mode 844 | # One fullscreen instance per display 845 | elif self.fullscreen_mode and stream.valid_video_fullscreen: 846 | self._player = PLAYER.VLCPLAYER 847 | 848 | player_cmd = ['cvlc', 849 | '--fullscreen', # VLC does not support windowed mode without X11 850 | '--network-caching=' + str(CONFIG.BUFFERTIME_MS), # Threshold of buffer in miliseconds 851 | '--no-keyboard-events', # No keyboard events 852 | '--mmal-display=hdmi-' + str(self._display_num), # Select the correct display 853 | '--mmal-layer=0', # OMXplayer uses layers starting from 0, don't interference 854 | '--input-timeshift-granularity=0', # Disable timeshift feature 855 | '--vout=mmal_vout', # Force MMAL mode 856 | '--gain=1', # Audio gain 857 | '--no-video-title-show' # Disable filename popup on start 858 | ] 859 | 860 | if not self.force_udp and not stream.force_udp: 861 | player_cmd.append('--rtsp-tcp') # Force RTSP over TCP 862 | 863 | # Keep in mind that VLC instances can be reused for 864 | # other windows with possibly other audio settings! 865 | # So don't disable the audio output to quickly! 866 | if CONFIG.AUDIO_MODE == AUDIOMODE.FULLSCREEN: 867 | 868 | # VLC does not have a command line volume argument?? 869 | pass 870 | 871 | else: 872 | player_cmd.append('--no-audio') # Disable audio stream 873 | 874 | if stream.url.startswith('file://'): 875 | player_cmd.append('--repeat') # Loop for local files (demo/test mode) 876 | 877 | # Show our channel name with a custom subtitle file? 878 | if sub_file and os.path.isfile(sub_file): 879 | player_cmd.extend(['--sub-file', sub_file]) # Add channel name as subtitle 880 | 881 | # TODO: we need te reopen VLC every time for the correct sub? 882 | if ((sub_file and os.path.isfile(sub_file)) or Window._vlc_subs_enabled[self._display_num - 1]) and \ 883 | self.get_vlc_pid(self._display_num): 884 | 885 | LOG.WARNING(self._LOG_NAME, "closing already active VLC instance for display '%i' " 886 | "as subtitles (video OSD) are enabled" % self._display_num) 887 | 888 | player_pid = self.get_vlc_pid(self._display_num) 889 | 890 | utils.terminate_process(player_pid, force=True) 891 | self._pidpool_remove_pid(player_pid) 892 | Window.vlc_player_pid[self._display_num - 1] = 0 893 | 894 | else: 895 | LOG.ERROR(self._LOG_NAME, "stream '%s' with codec '%s' is not valid for playback" % 896 | (stream.printable_url(), stream.codec_name)) 897 | return 898 | 899 | # Check hardware video decoder impact 900 | if Window._total_weight + self.get_weight(stream) > CONSTANTS.HW_DEC_MAX_WEIGTH and CONFIG.HARDWARE_CHECK: 901 | LOG.ERROR(self._LOG_NAME, "current hardware decoder weight is '%i', max decoder weight is '%i'" % 902 | (Window._total_weight, CONSTANTS.HW_DEC_MAX_WEIGTH)) 903 | return 904 | else: 905 | Window._total_weight += self.get_weight(stream) 906 | 907 | # Set URL before stripping 908 | self.active_stream = stream 909 | url = stream.url 910 | 911 | if self._player == PLAYER.VLCPLAYER and self.get_vlc_pid(self._display_num): 912 | 913 | LOG.DEBUG(self._LOG_NAME, "reusing already active VLC instance for display '%i'" % self._display_num) 914 | 915 | if self.visible: 916 | # VLC player instance can be playing or in idle state. 917 | # Sending the play command will start fullscreen playback of our video/stream. 918 | # When VLC is playing other content, we will hijack it. 919 | 920 | # Enable/disable audio 921 | if CONFIG.AUDIO_MODE == AUDIOMODE.FULLSCREEN: 922 | volume = CONFIG.AUDIO_VOLUME / 100 923 | self._send_dbus_command(DBUS_COMMAND.PLAY_VOLUME, volume) 924 | 925 | # Start our stream 926 | self._send_dbus_command(DBUS_COMMAND.PLAY_PLAY) 927 | 928 | # Mark our steam as the active one for this display 929 | Window._vlc_active_stream_url[self._display_num - 1] = self.active_stream.url 930 | 931 | else: 932 | # Play command will be sent by 'stream_set_visible' later on. 933 | pass 934 | 935 | # Pretend like the player just started again 936 | self.playstate = PLAYSTATE.INIT2 937 | self._time_streamstart = time.monotonic() 938 | return 939 | 940 | else: 941 | 942 | LOG.DEBUG(self._LOG_NAME, "starting player with arguments '%s'" % player_cmd) 943 | 944 | # Add URL now, as we don't want sensitive credentials in the logfile... 945 | if self._player == PLAYER.OMXPLAYER: 946 | player_cmd.append(url) 947 | 948 | elif self._player == PLAYER.VLCPLAYER and self.visible: 949 | player_cmd.append(url) 950 | Window._vlc_active_stream_url[self._display_num - 1] = url 951 | 952 | if self._player == PLAYER.VLCPLAYER: 953 | # VLC changes its DBus string to 'org.mpris.MediaPlayer2.vlc.instancePID' 954 | # when opening a second instance, so we have to adjust it later on when we know the PID 955 | # Max number of VLC instances = number of displays = 2 956 | 957 | if self._pidpool_get_pid("--mmal-display=hdmi-"): 958 | Window._vlc_dbus_ident[self._display_num - 1] = "org.mpris.MediaPlayer2.vlc.instance" 959 | else: 960 | Window._vlc_dbus_ident[self._display_num - 1] = "org.mpris.MediaPlayer2.vlc" 961 | 962 | # Save the subtitle state for later use 963 | Window._vlc_subs_enabled[self._display_num - 1] = (sub_file and os.path.isfile(sub_file)) 964 | 965 | subprocess.Popen(player_cmd, shell=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 966 | 967 | if self._player == PLAYER.VLCPLAYER: 968 | # VLC does not have a command line argument for volume control?? 969 | # As workaround, wait for VLC startup and send the desired volume with DBus 970 | time.sleep(0.5) 971 | self._send_dbus_command(DBUS_COMMAND.PLAY_VOLUME, CONFIG.AUDIO_VOLUME / 100, retries=5) 972 | 973 | self._time_streamstart = time.monotonic() 974 | self.playstate = PLAYSTATE.INIT1 975 | self._omx_duration = 0 976 | 977 | def player_initializing(self): 978 | """Check if the player is initializing, i.e. the player is not ready yet to accept DBus calls""" 979 | 980 | # Limit time consuming calls to 'get_stream_playstate()' 981 | if self.playstate == PLAYSTATE.INIT1: 982 | return self.get_stream_playstate() == PLAYSTATE.INIT1 983 | 984 | return False 985 | 986 | def player_buffering(self): 987 | """Check if the player is loading/buffering""" 988 | 989 | # Limit time consuming calls to 'get_stream_playstate()' 990 | if self.playstate == PLAYSTATE.INIT1 or self.playstate == PLAYSTATE.INIT2: 991 | return self.get_stream_playstate() == PLAYSTATE.INIT1 or self.get_stream_playstate() == PLAYSTATE.INIT2 992 | 993 | return False 994 | 995 | def get_omxplayer_pid(self): 996 | """Get OMXplayer instance PID for the requested display, 0 if not found""" 997 | 998 | return self._pidpool_get_pid(self._omx_dbus_ident) 999 | 1000 | @classmethod 1001 | def get_vlc_pid(cls, display_num): 1002 | """Get VLC instance PID for the requested display, 0 if not found""" 1003 | 1004 | return cls._pidpool_get_pid("--mmal-display=hdmi-" + str(display_num)) 1005 | 1006 | @classmethod 1007 | def stop_all_players(cls, sigkill=False): 1008 | """Stop all players the fast and hard way""" 1009 | 1010 | term_cmd = '-9' if sigkill else '-15' 1011 | 1012 | try: 1013 | subprocess.Popen( 1014 | ['killall', term_cmd, 'omxplayer.bin'], 1015 | shell=False, 1016 | stdout=subprocess.DEVNULL, 1017 | stderr=subprocess.DEVNULL 1018 | ) 1019 | 1020 | subprocess.Popen( 1021 | ['killall', term_cmd, 'vlc'], 1022 | shell=False, 1023 | stdout=subprocess.DEVNULL, 1024 | stderr=subprocess.DEVNULL 1025 | ) 1026 | except Exception as error: 1027 | LOG.ERROR(cls._LOG_NAME, "stop_all_players pid kill error: %s" % str(error)) 1028 | 1029 | # TODO: methods below are not thread safe 1030 | 1031 | @classmethod 1032 | def _pidpool_get_pid(cls, player_identification): 1033 | """Get Player PID from OS""" 1034 | 1035 | # PID already in PID pool 1036 | for idx, cmdline in enumerate(cls._player_pid_pool_cmdline[1]): 1037 | if player_identification in cmdline: 1038 | return int(cls._player_pid_pool_cmdline[0][idx]) 1039 | 1040 | # No? -> update PID pool 1041 | cls.pidpool_update() 1042 | 1043 | for idx, cmdline in enumerate(cls._player_pid_pool_cmdline[1]): 1044 | if player_identification in cmdline: 1045 | return int(cls._player_pid_pool_cmdline[0][idx]) 1046 | 1047 | return 0 1048 | 1049 | @classmethod 1050 | def _pidpool_remove_pid(cls, pid): 1051 | """Remove Player PID from pidpool""" 1052 | 1053 | for idx, _pid in enumerate(cls._player_pid_pool_cmdline[0]): 1054 | 1055 | if _pid == pid: 1056 | LOG.DEBUG("PIDpool", "removed Player PID '%i' from pool" % pid) 1057 | 1058 | del cls._player_pid_pool_cmdline[0][idx] 1059 | del cls._player_pid_pool_cmdline[1][idx] 1060 | return True 1061 | 1062 | return False 1063 | 1064 | @classmethod 1065 | def pidpool_update(cls): 1066 | """Update the PID pool of OMXplayer and VLC media player instances""" 1067 | 1068 | cls._player_pid_pool_cmdline = [[], []] 1069 | 1070 | try: 1071 | player_pids = subprocess.check_output(['pidof', 'vlc'], 1072 | universal_newlines=True, timeout=5).split() 1073 | 1074 | LOG.DEBUG("PIDpool", "active VLCplayer PIDs '%s'" % player_pids) 1075 | 1076 | for player_pid in player_pids: 1077 | cls._player_pid_pool_cmdline[0].append(int(player_pid)) 1078 | cls._player_pid_pool_cmdline[1].append(subprocess.check_output( 1079 | ['cat', str('/proc/%s/cmdline' % player_pid)], universal_newlines = True, timeout=5)) 1080 | 1081 | except subprocess.CalledProcessError: 1082 | pass 1083 | 1084 | try: 1085 | player_pids = subprocess.check_output(['pidof', 'omxplayer.bin'], 1086 | universal_newlines=True, timeout=5).split() 1087 | 1088 | LOG.DEBUG("PIDpool", "active OMXplayer PIDs '%s'" % player_pids) 1089 | 1090 | for player_pid in player_pids: 1091 | cls._player_pid_pool_cmdline[0].append(int(player_pid)) 1092 | cls._player_pid_pool_cmdline[1].append(subprocess.check_output( 1093 | ['cat', str('/proc/%s/cmdline' % player_pid)], universal_newlines = True, timeout=5)) 1094 | 1095 | except subprocess.CalledProcessError: 1096 | pass 1097 | -------------------------------------------------------------------------------- /examples/demo-3x3grid-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | channel1.3_url = file://../resources/video/big_buck_bunny_120p.mp4 6 | 7 | [DEVICE2] 8 | channel1_name = Elephants Dream 9 | channel1.1_url = file://../resources/video/elephants_dream_1080p.mp4 10 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 11 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 12 | 13 | [SCREEN1] 14 | layout = 9 15 | window1 = device1,channel1 16 | window2 = device2,channel1 17 | window3 = device1,channel1 18 | window4 = device2,channel1 19 | window5 = device1,channel1 20 | window6 = device2,channel1 21 | window7 = device1,channel1 22 | window8 = device2,channel1 23 | window9 = device1,channel1 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/demo-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | channel1.3_url = file://../resources/video/big_buck_bunny_120p.mp4 6 | 7 | [DEVICE2] 8 | channel1_name = Elephants Dream 9 | channel1.1_url = file://../resources/video/elephants_dream_1080p.mp4 10 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 11 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 12 | 13 | [SCREEN1] 14 | layout = 9 15 | window1 = device1,channel1 16 | window2 = device2,channel1 17 | window3 = device1,channel1 18 | window4 = device2,channel1 19 | window5 = device1,channel1 20 | window6 = device2,channel1 21 | window7 = device1,channel1 22 | window8 = device2,channel1 23 | window9 = device1,channel1 24 | 25 | [SCREEN2] 26 | layout = 6 27 | window1 = device1,channel1 28 | window2 = device2,channel1 29 | window3 = device2,channel1 30 | window4 = device2,channel1 31 | window5 = device2,channel1 32 | window6 = device2,channel1 33 | 34 | [SCREEN3] 35 | layout = 1 36 | window1 = device1,channel1 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/demo-pi4_dual_display-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | channel1.3_url = file://../resources/video/big_buck_bunny_120p.mp4 6 | 7 | [DEVICE2] 8 | channel1_name = Elephants Dream 9 | channel1.1_url = file://../resources/video/elephants_dream_1080p.mp4 10 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 11 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 12 | 13 | [SCREEN1] 14 | display = 1 15 | layout = 9 16 | window1 = device1,channel1 17 | window2 = device2,channel1 18 | window3 = device1,channel1 19 | window4 = device2,channel1 20 | window5 = device1,channel1 21 | window6 = device2,channel1 22 | window7 = device1,channel1 23 | window8 = device2,channel1 24 | window9 = device1,channel1 25 | 26 | [SCREEN2] 27 | display = 2 28 | layout = 6 29 | window1 = device1,channel1 30 | window2 = device2,channel1 31 | window3 = device1,channel1 32 | window4 = device2,channel1 33 | window5 = device1,channel1 34 | window6 = device2,channel1 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ `whoami` != root ]; then 4 | echo "Please run with sudo!" 5 | exit 1 6 | fi 7 | 8 | WORKDIR="$(dirname "$0")" 9 | cd $WORKDIR 10 | 11 | DESTPATH_APPDATA="/usr/local/share/camplayer/" 12 | DESTPATH_BIN="/usr/local/bin/camplayer" 13 | SYSTEMD_PATH="/lib/systemd/system/" 14 | 15 | # ------ Install files to the correct location ------- 16 | # ---------------------------------------------------- 17 | 18 | # Copy application 19 | echo "Copy appdate" 20 | mkdir -p $DESTPATH_APPDATA 21 | cp -v -R * $DESTPATH_APPDATA 22 | chmod 755 -R $DESTPATH_APPDATA 23 | 24 | # Copy executable 25 | echo "Copy executable" 26 | cp -v ./bin/camplayer $DESTPATH_BIN 27 | chmod 755 $DESTPATH_BIN 28 | 29 | # Be sure normal users can't read our config file! 30 | #chmod 600 $DESTPATH_APPDATA"settings.ini" 31 | 32 | # --- Install the required distribution packages ----- 33 | # ---------------------------------------------------- 34 | 35 | echo "Installing required distribution packages" 36 | apt-get update 37 | 38 | if [ ! -e /usr/bin/pip3 ]; then 39 | apt-get -y install python3-pip 40 | fi 41 | 42 | if [ ! -e /usr/bin/ffprobe ]; then 43 | apt-get -y install ffmpeg 44 | fi 45 | 46 | if [ ! -e /usr/bin/omxplayer ]; then 47 | apt-get -y install omxplayer 48 | fi 49 | 50 | # --------- Install required python packages --------- 51 | # ---------------------------------------------------- 52 | 53 | echo "Installing required python packages" 54 | pip3 show evdev 1>/dev/null 55 | if [ $? != 0 ]; then 56 | pip3 install evdev==1.2.0 57 | fi 58 | 59 | # ---------------- Systemd service ------------------- 60 | # ---------------------------------------------------- 61 | 62 | echo "Installing 'camplayer' as a systemd service" 63 | cp -v camplayer.service $SYSTEMD_PATH 64 | systemctl daemon-reload 65 | systemctl disable camplayer.service 66 | 67 | # ---------------------- pipng ----------------------- 68 | # ---------------------------------------------------- 69 | 70 | echo "Installing and building pipng" 71 | git clone https://github.com/raspicamplayer/pipng.git 72 | cd ./pipng/ && make && make install 73 | cd ../ 74 | rm -rf pipng 75 | 76 | # --------------------- Done! ------------------------ 77 | # ---------------------------------------------------- 78 | 79 | echo "Done!" 80 | -------------------------------------------------------------------------------- /resources/backgrounds/nolink_1P12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_1P12.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_1P5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_1P5.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_1P7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_1P7.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_1x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_1x1.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_2P8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_2P8.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_2x2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_2x2.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_3P4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_3P4.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_3x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_3x3.png -------------------------------------------------------------------------------- /resources/backgrounds/nolink_4x4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/backgrounds/nolink_4x4.png -------------------------------------------------------------------------------- /resources/icons/icon_control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/icons/icon_control.png -------------------------------------------------------------------------------- /resources/icons/icon_loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/icons/icon_loading.png -------------------------------------------------------------------------------- /resources/icons/icon_paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/icons/icon_paused.png -------------------------------------------------------------------------------- /resources/video/LICENSE.txt: -------------------------------------------------------------------------------- 1 | (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org 2 | (c) copyright 2006, Blender Foundation / Netherlands Media Art Institute / www.elephantsdream.org 3 | -------------------------------------------------------------------------------- /resources/video/big_buck_bunny_1080p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/big_buck_bunny_1080p.mp4 -------------------------------------------------------------------------------- /resources/video/big_buck_bunny_120p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/big_buck_bunny_120p.mp4 -------------------------------------------------------------------------------- /resources/video/big_buck_bunny_360p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/big_buck_bunny_360p.mp4 -------------------------------------------------------------------------------- /resources/video/big_buck_bunny_480p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/big_buck_bunny_480p.mp4 -------------------------------------------------------------------------------- /resources/video/big_buck_bunny_720p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/big_buck_bunny_720p.mp4 -------------------------------------------------------------------------------- /resources/video/big_buck_bunny_hevc_1080p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/big_buck_bunny_hevc_1080p.mp4 -------------------------------------------------------------------------------- /resources/video/elephants_dream_1080p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/elephants_dream_1080p.mp4 -------------------------------------------------------------------------------- /resources/video/elephants_dream_120p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/elephants_dream_120p.mp4 -------------------------------------------------------------------------------- /resources/video/elephants_dream_360p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/elephants_dream_360p.mp4 -------------------------------------------------------------------------------- /resources/video/elephants_dream_480p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/elephants_dream_480p.mp4 -------------------------------------------------------------------------------- /resources/video/elephants_dream_720p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/elephants_dream_720p.mp4 -------------------------------------------------------------------------------- /resources/video/elephants_dream_hevc_1080p.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/resources/video/elephants_dream_hevc_1080p.mp4 -------------------------------------------------------------------------------- /screenshots/camplayer_nolink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/screenshots/camplayer_nolink.png -------------------------------------------------------------------------------- /screenshots/camplayer_running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raspicamplayer/camplayer/a4d3a17f9d6ae06a57deb0fe3ca39ce5bf16a644/screenshots/camplayer_running.png -------------------------------------------------------------------------------- /tests/test-audio-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_hevc_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | channel1.3_url = file://../resources/video/big_buck_bunny_120p.mp4 6 | channel1.4_url = file://../resources/video/big_buck_bunny_1080p.mp4 7 | 8 | [DEVICE2] 9 | channel1_name = Elephants Dream 10 | channel1.1_url = file://../resources/video/elephants_dream_hevc_1080p.mp4 11 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 12 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 13 | channel1.4_url = file://../resources/video/elephants_dream_1080p.mp4 14 | 15 | [DEVICE3] 16 | channel1_name = Elephants Dream 17 | channel1.1_url = file://../resources/video/elephants_dream_hevc_1080p.mp4 18 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 19 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 20 | 21 | [DEVICE4] 22 | channel1_name = Big Buck Bunny 23 | channel1.1_url = file://../resources/video/big_buck_bunny_120p.mp4 24 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 25 | channel1.3_url = file://../resources/video/big_buck_bunny_1080p.mp4 26 | 27 | # SCREEN1 28 | # defaut: no audio 29 | # fullscreen mode: audio of fullscreen window 30 | [SCREEN1] 31 | layout = 4 32 | displaytime = 10 33 | window1 = device2,channel1 34 | window2 = device1,channel1 35 | window3 = device2,channel1 36 | window4 = device1,channel1 37 | 38 | # SCREEN2 39 | # plays audio with VLC 40 | [SCREEN2] 41 | layout = 1 42 | displaytime = 10 43 | window1 = device3,channel1 44 | 45 | # SCREEN3 46 | # plays audio with OMXplayer 47 | [SCREEN3] 48 | layout = 1 49 | displaytime = 10 50 | window1 = device4,channel1 51 | 52 | # SCREEN4 53 | # defaut: no audio 54 | # fullscreen mode: audio of fullscreen window 55 | [SCREEN4] 56 | layout = 4 57 | displaytime = 10 58 | window1 = device1,channel1 59 | window2 = device2,channel1 60 | window3 = device1,channel1 61 | window4 = device2,channel1 62 | 63 | # SCREEN4 64 | # defaut: no audio 65 | # fullscreen mode: audio of fullscreen window 66 | [SCREEN5] 67 | layout = 9 68 | displaytime = 10 69 | window1 = device1,channel1 70 | window2 = device2,channel1 71 | window3 = device1,channel1 72 | window4 = device2,channel1 73 | window5 = device1,channel1 74 | window6 = device2,channel1 75 | window7 = device1,channel1 76 | window8 = device2,channel1 77 | window9 = device1,channel1 78 | 79 | 80 | [ADVANCED] 81 | 82 | # Auto select best source 83 | streamquality = 1 84 | 85 | # 1 = enable audio in fullscreen mode 86 | enableaudio = 1 87 | 88 | # Set player audio volume (range 0..100) 89 | audiovolume = 30 90 | 91 | # Enable dynamic backgrounds 92 | backgroundmode = 2 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /tests/test-base16_grid-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_hevc_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | channel1.3_url = file://../resources/video/big_buck_bunny_120p.mp4 6 | 7 | [DEVICE2] 8 | channel1_name = Elephants Dream 9 | channel1.1_url = file://../resources/video/elephants_dream_hevc_1080p.mp4 10 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 11 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 12 | 13 | [SCREEN1] 14 | layout = 1 15 | displaytime = 10 16 | window1 = device1,channel1 17 | 18 | [SCREEN2] 19 | layout = 4 20 | displaytime = 10 21 | window1 = device1,channel1 22 | window2 = device2,channel1 23 | window3 = device2,channel1 24 | window4 = device1,channel1 25 | 26 | [SCREEN3] 27 | layout = 7 28 | displaytime = 10 29 | window1 = device2,channel1 30 | window2 = device1,channel1 31 | window3 = device1,channel1 32 | window4 = device2,channel1 33 | window5 = device2,channel1 34 | window6 = device2,channel1 35 | window7 = device2,channel1 36 | 37 | [SCREEN4] 38 | layout = 8 39 | displaytime = 10 40 | window1 = device1,channel1 41 | window2 = device2,channel1 42 | window3 = device2,channel1 43 | window4 = device2,channel1 44 | window5 = device2,channel1 45 | window6 = device2,channel1 46 | window7 = device2,channel1 47 | window8 = device2,channel1 48 | 49 | [SCREEN5] 50 | layout = 10 51 | displaytime = 10 52 | window1 = device1,channel1 53 | window2 = device2,channel1 54 | window3 = device2,channel1 55 | window4 = device2,channel1 56 | window5 = device2,channel1 57 | window6 = device2,channel1 58 | window7 = device1,channel1 59 | window8 = device1,channel1 60 | window9 = device1,channel1 61 | window10 = device1,channel1 62 | 63 | [SCREEN6] 64 | layout = 13 65 | displaytime = 10 66 | window1 = device1,channel1 67 | window2 = device2,channel1 68 | window3 = device2,channel1 69 | window4 = device2,channel1 70 | window5 = device2,channel1 71 | window6 = device2,channel1 72 | window7 = device2,channel1 73 | window8 = device1,channel1 74 | window9 = device1,channel1 75 | window10 = device2,channel1 76 | window11 = device2,channel1 77 | window12 = device1,channel1 78 | window13 = device1,channel1 79 | 80 | [SCREEN7] 81 | layout = 16 82 | displaytime = 10 83 | window1 = device1,channel1 84 | window2 = device1,channel1 85 | window3 = device2,channel1 86 | window4 = device2,channel1 87 | window5 = device1,channel1 88 | window6 = device1,channel1 89 | window7 = device2,channel1 90 | window8 = device2,channel1 91 | window9 = device2,channel1 92 | window10 = device2,channel1 93 | window11 = device1,channel1 94 | window12 = device1,channel1 95 | window13 = device2,channel1 96 | window14 = device2,channel1 97 | window15 = device1,channel1 98 | window16 = device1,channel1 99 | 100 | [ADVANCED] 101 | 102 | # 0 = Select lowest quality source/subchannel 103 | streamquality = 0 104 | 105 | # 2 = Enable dynamic backgrounds 106 | backgroundmode = 2 107 | 108 | # Downscale our screen by 10% 109 | screendownscale = 10 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /tests/test-base9_grid-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_hevc_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | channel1.3_url = file://../resources/video/big_buck_bunny_120p.mp4 6 | 7 | [DEVICE2] 8 | channel1_name = Elephants Dream 9 | channel1.1_url = file://../resources/video/elephants_dream_hevc_1080p.mp4 10 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 11 | channel1.3_url = file://../resources/video/elephants_dream_120p.mp4 12 | 13 | [SCREEN1] 14 | layout = 1 15 | displaytime = 10 16 | window1 = device1,channel1 17 | 18 | [SCREEN2] 19 | layout = 6 20 | displaytime = 10 21 | window1 = device1,channel1 22 | window2 = device2,channel1 23 | window3 = device2,channel1 24 | window4 = device2,channel1 25 | window5 = device2,channel1 26 | window6 = device2,channel1 27 | 28 | [SCREEN3] 29 | layout = 9 30 | displaytime = 10 31 | window1 = device1,channel1 32 | window2 = device2,channel1 33 | window3 = device1,channel1 34 | window4 = device2,channel1 35 | window5 = device1,channel1 36 | window6 = device2,channel1 37 | window7 = device1,channel1 38 | window8 = device2,channel1 39 | window9 = device1,channel1 40 | 41 | [ADVANCED] 42 | 43 | # 1 = Auto select source/subchannel 44 | streamquality = 1 45 | 46 | # 2 = Enable dynamic backgrounds 47 | backgroundmode = 2 48 | 49 | # Downscale our screen by 10% 50 | screendownscale = 10 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /tests/test-downscaling-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_hevc_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | 6 | [DEVICE2] 7 | channel1_name = Elephants Dream 8 | channel1.1_url = file://../resources/video/elephants_dream_hevc_1080p.mp4 9 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 10 | 11 | [DEVICE3] 12 | channel1_name = Big Buck Bunny 13 | channel1.1_url = file://../resources/video/big_buck_bunny_360p.mp4 14 | channel1.2_url = file://../resources/video/big_buck_bunny_1080p.mp4 15 | 16 | [SCREEN1] 17 | layout = 6 18 | displaytime = 10 19 | window1 = device3,channel1 20 | window2 = device2,channel1 21 | window3 = device2,channel1 22 | window4 = device2,channel1 23 | window5 = device2,channel1 24 | window6 = device2,channel1 25 | 26 | [SCREEN2] 27 | layout = 9 28 | displaytime = 10 29 | window1 = device1,channel1 30 | window2 = device2,channel1 31 | window3 = device1,channel1 32 | window4 = device2,channel1 33 | window5 = device1,channel1 34 | window6 = device2,channel1 35 | window7 = device1,channel1 36 | window8 = device2,channel1 37 | window9 = device1,channel1 38 | 39 | 40 | [ADVANCED] 41 | 42 | # 1 = Select highest (sensible) quality source/subchannel 43 | streamquality = 2 44 | 45 | # 2 = Enable dynamic backgrounds 46 | backgroundmode = 2 47 | 48 | # Downscale our screen by 20% 49 | screendownscale = 20 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /tests/test-hevc-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_hevc_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | 6 | [DEVICE2] 7 | channel1_name = Elephants Dream 8 | channel1.1_url = file://../resources/video/elephants_dream_hevc_1080p.mp4 9 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 10 | 11 | [SCREEN1] 12 | layout = 1 13 | displaytime = 2 14 | window1 = device1,channel1 15 | 16 | [SCREEN2] 17 | layout = 1 18 | displaytime = 2 19 | window1 = device2,channel1 20 | 21 | [SCREEN3] 22 | layout = 1 23 | displaytime = 2 24 | window1 = device1,channel1 25 | 26 | [SCREEN4] 27 | layout = 4 28 | displaytime = 2 29 | window1 = device1,channel1 30 | window2 = device2,channel1 31 | window3 = device2,channel1 32 | window4 = device1,channel1 33 | 34 | [ADVANCED] 35 | 36 | # 1 = Select highest (sensible) quality source/subchannel 37 | streamquality = 2 38 | 39 | # 2 = Enable dynamic backgrounds 40 | backgroundmode = 2 41 | 42 | screenchangeover = 2 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /tests/test-performance_1080pH264-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_1080p.mp4 4 | 5 | [DEVICE2] 6 | channel1_name = Elephants Dream 7 | channel1.1_url = file://../resources/video/elephants_dream_1080p.mp4 8 | 9 | [SCREEN1] 10 | layout = 1 11 | window1 = device1,channel1 12 | 13 | [SCREEN2] 14 | layout = 4 15 | window1 = device1,channel1 16 | window2 = device2,channel1 17 | window3 = device2,channel1 18 | window4 = device1,channel1 19 | 20 | [SCREEN3] 21 | layout = 6 22 | window1 = device1,channel1 23 | window2 = device2,channel1 24 | window3 = device2,channel1 25 | window4 = device2,channel1 26 | window5 = device2,channel1 27 | window6 = device2,channel1 28 | 29 | [SCREEN4] 30 | layout = 7 31 | window1 = device2,channel1 32 | window2 = device1,channel1 33 | window3 = device1,channel1 34 | window4 = device2,channel1 35 | window5 = device2,channel1 36 | window6 = device2,channel1 37 | window7 = device2,channel1 38 | 39 | [SCREEN5] 40 | layout = 8 41 | window1 = device1,channel1 42 | window2 = device2,channel1 43 | window3 = device2,channel1 44 | window4 = device2,channel1 45 | window5 = device2,channel1 46 | window6 = device2,channel1 47 | window7 = device2,channel1 48 | window8 = device2,channel1 49 | 50 | [SCREEN6] 51 | layout = 9 52 | window1 = device1,channel1 53 | window2 = device2,channel1 54 | window3 = device1,channel1 55 | window4 = device2,channel1 56 | window5 = device1,channel1 57 | window6 = device2,channel1 58 | window7 = device1,channel1 59 | window8 = device2,channel1 60 | window9 = device1,channel1 61 | 62 | [SCREEN7] 63 | layout = 10 64 | window1 = device1,channel1 65 | window2 = device2,channel1 66 | window3 = device2,channel1 67 | window4 = device2,channel1 68 | window5 = device2,channel1 69 | window6 = device2,channel1 70 | window7 = device1,channel1 71 | window8 = device1,channel1 72 | window9 = device1,channel1 73 | window10 = device1,channel1 74 | 75 | [SCREEN8] 76 | layout = 13 77 | window1 = device1,channel1 78 | window2 = device2,channel1 79 | window3 = device2,channel1 80 | window4 = device2,channel1 81 | window5 = device2,channel1 82 | window6 = device2,channel1 83 | window7 = device2,channel1 84 | window8 = device1,channel1 85 | window9 = device1,channel1 86 | window10 = device2,channel1 87 | window11 = device2,channel1 88 | window12 = device1,channel1 89 | window13 = device1,channel1 90 | 91 | [SCREEN9] 92 | layout = 16 93 | window1 = device1,channel1 94 | window2 = device1,channel1 95 | window3 = device2,channel1 96 | window4 = device2,channel1 97 | window5 = device1,channel1 98 | window6 = device1,channel1 99 | window7 = device2,channel1 100 | window8 = device2,channel1 101 | window9 = device2,channel1 102 | window10 = device2,channel1 103 | window11 = device1,channel1 104 | window12 = device1,channel1 105 | window13 = device2,channel1 106 | window14 = device2,channel1 107 | window15 = device1,channel1 108 | window16 = device1,channel1 109 | 110 | [ADVANCED] 111 | 112 | # 2 = Enable dynamic backgrounds 113 | backgroundmode = 2 114 | 115 | # Disable automatic screen rotation 116 | showtime = 0 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /tests/test-performance_360pH264-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_360p.mp4 4 | 5 | [DEVICE2] 6 | channel1_name = Elephants Dream 7 | channel1.1_url = file://../resources/video/elephants_dream_360p.mp4 8 | 9 | [SCREEN1] 10 | layout = 1 11 | window1 = device1,channel1 12 | 13 | [SCREEN2] 14 | layout = 4 15 | window1 = device1,channel1 16 | window2 = device2,channel1 17 | window3 = device2,channel1 18 | window4 = device1,channel1 19 | 20 | [SCREEN3] 21 | layout = 6 22 | window1 = device1,channel1 23 | window2 = device2,channel1 24 | window3 = device2,channel1 25 | window4 = device2,channel1 26 | window5 = device2,channel1 27 | window6 = device2,channel1 28 | 29 | [SCREEN4] 30 | layout = 7 31 | window1 = device2,channel1 32 | window2 = device1,channel1 33 | window3 = device1,channel1 34 | window4 = device2,channel1 35 | window5 = device2,channel1 36 | window6 = device2,channel1 37 | window7 = device2,channel1 38 | 39 | [SCREEN5] 40 | layout = 8 41 | window1 = device1,channel1 42 | window2 = device2,channel1 43 | window3 = device2,channel1 44 | window4 = device2,channel1 45 | window5 = device2,channel1 46 | window6 = device2,channel1 47 | window7 = device2,channel1 48 | window8 = device2,channel1 49 | 50 | [SCREEN6] 51 | layout = 9 52 | window1 = device1,channel1 53 | window2 = device2,channel1 54 | window3 = device1,channel1 55 | window4 = device2,channel1 56 | window5 = device1,channel1 57 | window6 = device2,channel1 58 | window7 = device1,channel1 59 | window8 = device2,channel1 60 | window9 = device1,channel1 61 | 62 | [SCREEN7] 63 | layout = 10 64 | window1 = device1,channel1 65 | window2 = device2,channel1 66 | window3 = device2,channel1 67 | window4 = device2,channel1 68 | window5 = device2,channel1 69 | window6 = device2,channel1 70 | window7 = device1,channel1 71 | window8 = device1,channel1 72 | window9 = device1,channel1 73 | window10 = device1,channel1 74 | 75 | [SCREEN8] 76 | layout = 13 77 | window1 = device1,channel1 78 | window2 = device2,channel1 79 | window3 = device2,channel1 80 | window4 = device2,channel1 81 | window5 = device2,channel1 82 | window6 = device2,channel1 83 | window7 = device2,channel1 84 | window8 = device1,channel1 85 | window9 = device1,channel1 86 | window10 = device2,channel1 87 | window11 = device2,channel1 88 | window12 = device1,channel1 89 | window13 = device1,channel1 90 | 91 | [SCREEN9] 92 | layout = 16 93 | window1 = device1,channel1 94 | window2 = device1,channel1 95 | window3 = device2,channel1 96 | window4 = device2,channel1 97 | window5 = device1,channel1 98 | window6 = device1,channel1 99 | window7 = device2,channel1 100 | window8 = device2,channel1 101 | window9 = device2,channel1 102 | window10 = device2,channel1 103 | window11 = device1,channel1 104 | window12 = device1,channel1 105 | window13 = device2,channel1 106 | window14 = device2,channel1 107 | window15 = device1,channel1 108 | window16 = device1,channel1 109 | 110 | [ADVANCED] 111 | 112 | # 2 = Enable dynamic backgrounds 113 | backgroundmode = 2 114 | 115 | # Disable automatic screen rotation 116 | showtime = 0 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /tests/test-performance_360pH264_dualscreen-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_360p.mp4 4 | 5 | [DEVICE2] 6 | channel1_name = Elephants Dream 7 | channel1.1_url = file://../resources/video/elephants_dream_360p.mp4 8 | 9 | [SCREEN1] 10 | layout = 1 11 | window1 = device1,channel1 12 | 13 | [SCREEN21] 14 | layout = 1 15 | display = 2 16 | window1 = device1,channel1 17 | 18 | [SCREEN2] 19 | layout = 4 20 | window1 = device1,channel1 21 | window2 = device2,channel1 22 | window3 = device2,channel1 23 | window4 = device1,channel1 24 | 25 | [SCREEN22] 26 | layout = 4 27 | display = 2 28 | window1 = device1,channel1 29 | window2 = device2,channel1 30 | window3 = device2,channel1 31 | window4 = device1,channel1 32 | 33 | [SCREEN3] 34 | layout = 6 35 | window1 = device1,channel1 36 | window2 = device2,channel1 37 | window3 = device2,channel1 38 | window4 = device2,channel1 39 | window5 = device2,channel1 40 | window6 = device2,channel1 41 | 42 | [SCREEN23] 43 | layout = 6 44 | display = 2 45 | window1 = device1,channel1 46 | window2 = device2,channel1 47 | window3 = device2,channel1 48 | window4 = device2,channel1 49 | window5 = device2,channel1 50 | window6 = device2,channel1 51 | 52 | [SCREEN4] 53 | layout = 7 54 | window1 = device2,channel1 55 | window2 = device1,channel1 56 | window3 = device1,channel1 57 | window4 = device2,channel1 58 | window5 = device2,channel1 59 | window6 = device2,channel1 60 | window7 = device2,channel1 61 | 62 | [SCREEN24] 63 | layout = 7 64 | display = 2 65 | window1 = device2,channel1 66 | window2 = device1,channel1 67 | window3 = device1,channel1 68 | window4 = device2,channel1 69 | window5 = device2,channel1 70 | window6 = device2,channel1 71 | window7 = device2,channel1 72 | 73 | [SCREEN5] 74 | layout = 8 75 | window1 = device1,channel1 76 | window2 = device2,channel1 77 | window3 = device2,channel1 78 | window4 = device2,channel1 79 | window5 = device2,channel1 80 | window6 = device2,channel1 81 | window7 = device2,channel1 82 | window8 = device2,channel1 83 | 84 | [SCREEN25] 85 | layout = 8 86 | display = 2 87 | window1 = device1,channel1 88 | window2 = device2,channel1 89 | window3 = device2,channel1 90 | window4 = device2,channel1 91 | window5 = device2,channel1 92 | window6 = device2,channel1 93 | window7 = device2,channel1 94 | window8 = device2,channel1 95 | 96 | [SCREEN6] 97 | layout = 9 98 | window1 = device1,channel1 99 | window2 = device2,channel1 100 | window3 = device1,channel1 101 | window4 = device2,channel1 102 | window5 = device1,channel1 103 | window6 = device2,channel1 104 | window7 = device1,channel1 105 | window8 = device2,channel1 106 | window9 = device1,channel1 107 | 108 | [SCREEN26] 109 | layout = 9 110 | display = 2 111 | window1 = device1,channel1 112 | window2 = device2,channel1 113 | window3 = device1,channel1 114 | window4 = device2,channel1 115 | window5 = device1,channel1 116 | window6 = device2,channel1 117 | window7 = device1,channel1 118 | window8 = device2,channel1 119 | window9 = device1,channel1 120 | 121 | [ADVANCED] 122 | 123 | # 2 = Enable dynamic backgrounds 124 | backgroundmode = 2 125 | 126 | # Disable automatic screen rotation 127 | showtime = 0 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /tests/test-performance_480pH264-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_480p.mp4 4 | 5 | [DEVICE2] 6 | channel1_name = Elephants Dream 7 | channel1.1_url = file://../resources/video/elephants_dream_480p.mp4 8 | 9 | [SCREEN1] 10 | layout = 1 11 | window1 = device1,channel1 12 | 13 | [SCREEN2] 14 | layout = 4 15 | window1 = device1,channel1 16 | window2 = device2,channel1 17 | window3 = device2,channel1 18 | window4 = device1,channel1 19 | 20 | [SCREEN3] 21 | layout = 6 22 | window1 = device1,channel1 23 | window2 = device2,channel1 24 | window3 = device2,channel1 25 | window4 = device2,channel1 26 | window5 = device2,channel1 27 | window6 = device2,channel1 28 | 29 | [SCREEN4] 30 | layout = 7 31 | window1 = device2,channel1 32 | window2 = device1,channel1 33 | window3 = device1,channel1 34 | window4 = device2,channel1 35 | window5 = device2,channel1 36 | window6 = device2,channel1 37 | window7 = device2,channel1 38 | 39 | [SCREEN5] 40 | layout = 8 41 | window1 = device1,channel1 42 | window2 = device2,channel1 43 | window3 = device2,channel1 44 | window4 = device2,channel1 45 | window5 = device2,channel1 46 | window6 = device2,channel1 47 | window7 = device2,channel1 48 | window8 = device2,channel1 49 | 50 | [SCREEN6] 51 | layout = 9 52 | window1 = device1,channel1 53 | window2 = device2,channel1 54 | window3 = device1,channel1 55 | window4 = device2,channel1 56 | window5 = device1,channel1 57 | window6 = device2,channel1 58 | window7 = device1,channel1 59 | window8 = device2,channel1 60 | window9 = device1,channel1 61 | 62 | [SCREEN7] 63 | layout = 10 64 | window1 = device1,channel1 65 | window2 = device2,channel1 66 | window3 = device2,channel1 67 | window4 = device2,channel1 68 | window5 = device2,channel1 69 | window6 = device2,channel1 70 | window7 = device1,channel1 71 | window8 = device1,channel1 72 | window9 = device1,channel1 73 | window10 = device1,channel1 74 | 75 | [SCREEN8] 76 | layout = 13 77 | window1 = device1,channel1 78 | window2 = device2,channel1 79 | window3 = device2,channel1 80 | window4 = device2,channel1 81 | window5 = device2,channel1 82 | window6 = device2,channel1 83 | window7 = device2,channel1 84 | window8 = device1,channel1 85 | window9 = device1,channel1 86 | window10 = device2,channel1 87 | window11 = device2,channel1 88 | window12 = device1,channel1 89 | window13 = device1,channel1 90 | 91 | [SCREEN9] 92 | layout = 16 93 | window1 = device1,channel1 94 | window2 = device1,channel1 95 | window3 = device2,channel1 96 | window4 = device2,channel1 97 | window5 = device1,channel1 98 | window6 = device1,channel1 99 | window7 = device2,channel1 100 | window8 = device2,channel1 101 | window9 = device2,channel1 102 | window10 = device2,channel1 103 | window11 = device1,channel1 104 | window12 = device1,channel1 105 | window13 = device2,channel1 106 | window14 = device2,channel1 107 | window15 = device1,channel1 108 | window16 = device1,channel1 109 | 110 | [ADVANCED] 111 | 112 | # 2 = Enable dynamic backgrounds 113 | backgroundmode = 2 114 | 115 | # Disable automatic screen rotation 116 | showtime = 0 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /tests/test-performance_480pH264_dualscreen-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_480p.mp4 4 | 5 | [DEVICE2] 6 | channel1_name = Elephants Dream 7 | channel1.1_url = file://../resources/video/elephants_dream_480p.mp4 8 | 9 | [SCREEN1] 10 | layout = 1 11 | window1 = device1,channel1 12 | 13 | [SCREEN21] 14 | layout = 1 15 | display = 2 16 | window1 = device1,channel1 17 | 18 | [SCREEN2] 19 | layout = 4 20 | window1 = device1,channel1 21 | window2 = device2,channel1 22 | window3 = device2,channel1 23 | window4 = device1,channel1 24 | 25 | [SCREEN22] 26 | layout = 4 27 | display = 2 28 | window1 = device1,channel1 29 | window2 = device2,channel1 30 | window3 = device2,channel1 31 | window4 = device1,channel1 32 | 33 | [SCREEN3] 34 | layout = 6 35 | window1 = device1,channel1 36 | window2 = device2,channel1 37 | window3 = device2,channel1 38 | window4 = device2,channel1 39 | window5 = device2,channel1 40 | window6 = device2,channel1 41 | 42 | [SCREEN23] 43 | layout = 6 44 | display = 2 45 | window1 = device1,channel1 46 | window2 = device2,channel1 47 | window3 = device2,channel1 48 | window4 = device2,channel1 49 | window5 = device2,channel1 50 | window6 = device2,channel1 51 | 52 | [SCREEN4] 53 | layout = 7 54 | window1 = device2,channel1 55 | window2 = device1,channel1 56 | window3 = device1,channel1 57 | window4 = device2,channel1 58 | window5 = device2,channel1 59 | window6 = device2,channel1 60 | window7 = device2,channel1 61 | 62 | [SCREEN24] 63 | layout = 7 64 | display = 2 65 | window1 = device2,channel1 66 | window2 = device1,channel1 67 | window3 = device1,channel1 68 | window4 = device2,channel1 69 | window5 = device2,channel1 70 | window6 = device2,channel1 71 | window7 = device2,channel1 72 | 73 | [SCREEN5] 74 | layout = 8 75 | window1 = device1,channel1 76 | window2 = device2,channel1 77 | window3 = device2,channel1 78 | window4 = device2,channel1 79 | window5 = device2,channel1 80 | window6 = device2,channel1 81 | window7 = device2,channel1 82 | window8 = device2,channel1 83 | 84 | [SCREEN25] 85 | layout = 8 86 | display = 2 87 | window1 = device1,channel1 88 | window2 = device2,channel1 89 | window3 = device2,channel1 90 | window4 = device2,channel1 91 | window5 = device2,channel1 92 | window6 = device2,channel1 93 | window7 = device2,channel1 94 | window8 = device2,channel1 95 | 96 | [SCREEN6] 97 | layout = 9 98 | window1 = device1,channel1 99 | window2 = device2,channel1 100 | window3 = device1,channel1 101 | window4 = device2,channel1 102 | window5 = device1,channel1 103 | window6 = device2,channel1 104 | window7 = device1,channel1 105 | window8 = device2,channel1 106 | window9 = device1,channel1 107 | 108 | [SCREEN26] 109 | layout = 9 110 | display = 2 111 | window1 = device1,channel1 112 | window2 = device2,channel1 113 | window3 = device1,channel1 114 | window4 = device2,channel1 115 | window5 = device1,channel1 116 | window6 = device2,channel1 117 | window7 = device1,channel1 118 | window8 = device2,channel1 119 | window9 = device1,channel1 120 | 121 | [ADVANCED] 122 | 123 | # 2 = Enable dynamic backgrounds 124 | backgroundmode = 2 125 | 126 | # Disable automatic screen rotation 127 | showtime = 0 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /tests/test-performance_720pH264-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_720p.mp4 4 | 5 | [DEVICE2] 6 | channel1_name = Elephants Dream 7 | channel1.1_url = file://../resources/video/elephants_dream_720p.mp4 8 | 9 | [SCREEN1] 10 | layout = 1 11 | window1 = device1,channel1 12 | 13 | [SCREEN2] 14 | layout = 4 15 | window1 = device1,channel1 16 | window2 = device2,channel1 17 | window3 = device2,channel1 18 | window4 = device1,channel1 19 | 20 | [SCREEN3] 21 | layout = 6 22 | window1 = device1,channel1 23 | window2 = device2,channel1 24 | window3 = device2,channel1 25 | window4 = device2,channel1 26 | window5 = device2,channel1 27 | window6 = device2,channel1 28 | 29 | [SCREEN4] 30 | layout = 7 31 | window1 = device2,channel1 32 | window2 = device1,channel1 33 | window3 = device1,channel1 34 | window4 = device2,channel1 35 | window5 = device2,channel1 36 | window6 = device2,channel1 37 | window7 = device2,channel1 38 | 39 | [SCREEN5] 40 | layout = 8 41 | window1 = device1,channel1 42 | window2 = device2,channel1 43 | window3 = device2,channel1 44 | window4 = device2,channel1 45 | window5 = device2,channel1 46 | window6 = device2,channel1 47 | window7 = device2,channel1 48 | window8 = device2,channel1 49 | 50 | [SCREEN6] 51 | layout = 9 52 | window1 = device1,channel1 53 | window2 = device2,channel1 54 | window3 = device1,channel1 55 | window4 = device2,channel1 56 | window5 = device1,channel1 57 | window6 = device2,channel1 58 | window7 = device1,channel1 59 | window8 = device2,channel1 60 | window9 = device1,channel1 61 | 62 | [SCREEN7] 63 | layout = 10 64 | window1 = device1,channel1 65 | window2 = device2,channel1 66 | window3 = device2,channel1 67 | window4 = device2,channel1 68 | window5 = device2,channel1 69 | window6 = device2,channel1 70 | window7 = device1,channel1 71 | window8 = device1,channel1 72 | window9 = device1,channel1 73 | window10 = device1,channel1 74 | 75 | [SCREEN8] 76 | layout = 13 77 | window1 = device1,channel1 78 | window2 = device2,channel1 79 | window3 = device2,channel1 80 | window4 = device2,channel1 81 | window5 = device2,channel1 82 | window6 = device2,channel1 83 | window7 = device2,channel1 84 | window8 = device1,channel1 85 | window9 = device1,channel1 86 | window10 = device2,channel1 87 | window11 = device2,channel1 88 | window12 = device1,channel1 89 | window13 = device1,channel1 90 | 91 | [SCREEN9] 92 | layout = 16 93 | window1 = device1,channel1 94 | window2 = device1,channel1 95 | window3 = device2,channel1 96 | window4 = device2,channel1 97 | window5 = device1,channel1 98 | window6 = device1,channel1 99 | window7 = device2,channel1 100 | window8 = device2,channel1 101 | window9 = device2,channel1 102 | window10 = device2,channel1 103 | window11 = device1,channel1 104 | window12 = device1,channel1 105 | window13 = device2,channel1 106 | window14 = device2,channel1 107 | window15 = device1,channel1 108 | window16 = device1,channel1 109 | 110 | [ADVANCED] 111 | 112 | # 2 = Enable dynamic backgrounds 113 | backgroundmode = 2 114 | 115 | # Disable automatic screen rotation 116 | showtime = 0 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /tests/test-video_osd-config.ini: -------------------------------------------------------------------------------- 1 | [DEVICE1] 2 | channel1_name = Big Buck Bunny 3 | channel1.1_url = file://../resources/video/big_buck_bunny_1080p.mp4 4 | channel1.2_url = file://../resources/video/big_buck_bunny_360p.mp4 5 | 6 | [DEVICE2] 7 | channel1_name = Elephants Dream 8 | channel1.1_url = file://../resources/video/elephants_dream_1080p.mp4 9 | channel1.2_url = file://../resources/video/elephants_dream_360p.mp4 10 | 11 | [SCREEN1] 12 | layout = 4 13 | displaytime = 10 14 | window1 = device1,channel1 15 | window2 = device2,channel1 16 | window3 = device1,channel1 17 | window4 = device2,channel1 18 | 19 | [ADVANCED] 20 | 21 | # 2 = Enable dynamic backgrounds 22 | backgroundmode = 2 23 | 24 | # 1 = Show channel name on top of the video 25 | enablevideoosd = 1 26 | 27 | # 0 = Disable stream refresh interval 28 | refreshtime = 0 29 | 30 | 31 | 32 | 33 | 34 | --------------------------------------------------------------------------------