commit 04f88d8294fe0ad6d66a08ae5583c1d179d039a8 Author: knotteye Date: Thu Apr 15 15:16:38 2021 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..63a9329 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +ifeq ($(PREFIX),) + PREFIX := /usr/local +endif + + +all: release +release: + nuitka3 --follow-imports --prefer-source-code plchat.py \ No newline at end of file diff --git a/audiowidget.py b/audiowidget.py new file mode 100644 index 0000000..d1422d1 --- /dev/null +++ b/audiowidget.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python + + +############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# + +from PyQt5 import QtWidgets, QtCore +from PyQt5.QtCore import QDir, Qt, QUrl, QSize +from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer, QMediaPlaylist +from PyQt5.QtMultimediaWidgets import QVideoWidget +from PyQt5.QtWidgets import (QApplication, QFileDialog, QHBoxLayout, QLabel, + QPushButton, QSizePolicy, QSlider, QStyle, QVBoxLayout, QWidget, QDesktopWidget) +from PyQt5.QtGui import QDesktopServices + + +class AudioPlayer(QWidget): + + def __init__(self, parent=None): + super(AudioPlayer, self).__init__(parent) + + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + + self.mediaPlayer = QMediaPlayer(None, QMediaPlayer.LowLatency) + + self.playlist = QMediaPlaylist() + self.playlist.setPlaybackMode(QMediaPlaylist.Loop) + + self.mediaPlayer.setPlaylist(self.playlist) + + self.playButton = QPushButton() + self.playButton.setEnabled(False) + self.playButton.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) + self.playButton.clicked.connect(self.play) + + self.positionSlider = ClickSlider() + self.positionSlider.setOrientation(Qt.Horizontal) + self.positionSlider.setRange(0, 0) + self.positionSlider.sliderMoved.connect(self.setPosition) + + self.volumeButton = QPushButton() + self.volumeButton.setEnabled(False) + self.volumeButton.setIcon(self.style().standardIcon(QStyle.SP_MediaVolume)) + self.volumeButton.clicked.connect(self.doMute) + + self.errorLabel = QLabel() + self.errorLabel.setSizePolicy(QSizePolicy.Preferred, + QSizePolicy.Maximum) + + controlLayout = QHBoxLayout() + controlLayout.setContentsMargins(0, 0, 0, 0) + controlLayout.addWidget(self.playButton, 1) + controlLayout.addWidget(self.positionSlider, 10) + controlLayout.addWidget(self.volumeButton, 1) + + layout = QVBoxLayout() + layout.addLayout(controlLayout) + layout.addWidget(self.errorLabel) + + self.setLayout(layout) + + self.mediaPlayer.stateChanged.connect(self.mediaStateChanged) + self.mediaPlayer.positionChanged.connect(self.positionChanged) + self.mediaPlayer.durationChanged.connect(self.durationChanged) + self.mediaPlayer.error.connect(self.handleError) + + def openFile(self, fileName): + if fileName != '': + fileName = fileName + + self.playlist.addMedia(QMediaContent(QUrl(fileName))) + self.playlist.next() + + + self.mediaPlayer.play() + self.mediaPlayer.pause() + #self.mediaPlayer.setMedia( + # QMediaContent(QUrl(fileName))) + #QMediaContent(QUrl.fromLocalFile(fileName))) + self.playButton.setEnabled(True) + self.volumeButton.setEnabled(True) + #self.mediaPlayer.playlist().setPlaybackMode(QMediaPlaylist.Loop) + + def play(self): + if self.mediaPlayer.state() == QMediaPlayer.PlayingState: + self.mediaPlayer.pause() + else: + self.mediaPlayer.play() + + def mediaStateChanged(self, state): + if self.mediaPlayer.state() == QMediaPlayer.PlayingState: + self.playButton.setIcon( + self.style().standardIcon(QStyle.SP_MediaPause)) + else: + self.playButton.setIcon( + self.style().standardIcon(QStyle.SP_MediaPlay)) + + def positionChanged(self, position): + self.positionSlider.setValue(position) + + def durationChanged(self, duration): + self.positionSlider.setRange(0, duration) + + def setPosition(self, position): + self.mediaPlayer.setPosition(position) + + def doMute(self, checked): + self.mediaPlayer.setMuted(not self.mediaPlayer.isMuted()) + if self.mediaPlayer.isMuted(): + self.volumeButton.setIcon(self.style().standardIcon(QStyle.SP_MediaVolumeMuted)) + else: + self.volumeButton.setIcon(self.style().standardIcon(QStyle.SP_MediaVolume)) + + def handleError(self): + self.playButton.setEnabled(False) + self.errorLabel.setText("Error: " + self.mediaPlayer.errorString()) + + def sizeHint(self): + return QSize(250, self.volumeButton.height()) + +class ClickSlider(QtWidgets.QSlider): + def mousePressEvent(self, event): + super(ClickSlider, self).mousePressEvent(event) + if event.button() == QtCore.Qt.LeftButton: + val = self.pixelPosToRangeValue(event.pos()) + self.setValue(val) + + def pixelPosToRangeValue(self, pos): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderHandle, self) + + if self.orientation() == QtCore.Qt.Horizontal: + sliderLength = sr.width() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + else: + sliderLength = sr.height() + sliderMin = gr.y() + sliderMax = gr.bottom() - sliderLength + 1; + pr = pos - sr.center() + sr.topLeft() + p = pr.x() if self.orientation() == QtCore.Qt.Horizontal else pr.y() + return QtWidgets.QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), p - sliderMin, + sliderMax - sliderMin, opt.upsideDown) + +if __name__ == '__main__': + + import sys + + app = QApplication(sys.argv) + + player = VideoPlayer() + player.resize(320, 240) + player.show() + + sys.exit(app.exec_()) diff --git a/fedi.svg b/fedi.svg new file mode 100644 index 0000000..91c1822 --- /dev/null +++ b/fedi.svg @@ -0,0 +1,45 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/misc.py b/misc.py new file mode 100644 index 0000000..853b8b0 --- /dev/null +++ b/misc.py @@ -0,0 +1,715 @@ +gpltext=''' GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. +''' +videowidgetlicense = '''############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# +''' \ No newline at end of file diff --git a/monkeypatch.py b/monkeypatch.py new file mode 100644 index 0000000..1a0b74a --- /dev/null +++ b/monkeypatch.py @@ -0,0 +1,34 @@ +import os, time, platform +from ctypes import cdll, util + +#try: +# if os.name == 'posix': +# libc = cdll.LoadLibrary("libc.so.6") +# elif os.name == 'nt': +# libc = cdll.kernel32 +#except: +# libc = None +try: + if platform.system() == "Windows": + libc_path = util.find_library("msvcrt") + else: + libc_path = util.find_library("c") + libc = cdll.LoadLibrary(libc_path) + print("Loaded libc from "+libc_path) +except: + libc_path = None + libc = None + +def posixsleep(t): + libc.usleep(int(t * 1000000)) + +def ntsleep(t): + libc.Sleep(int(t * 1000)) + +if libc_path == 'libc.so.6': + sleep = posixsleep +elif libc_path == 'kernel32': + sleep = ntsleep +else: + print("Couldn't figure out how to use native sleep calls with "+str(libc_path)+", event loop performance may suffer.") + sleep = time.sleep \ No newline at end of file diff --git a/plchat.py b/plchat.py new file mode 100644 index 0000000..901aa0f --- /dev/null +++ b/plchat.py @@ -0,0 +1,1419 @@ +from PyQt5.QtGui import * +from PyQt5.QtCore import * +from PyQt5.QtWidgets import * +from notifypy import Notify +import sys, keyring, threading, queue, asyncio, urllib, appdirs, os, time, pleroma, re, magic, monkeypatch, requests, misc, json, timeconvert, videowidget, audiowidget + +CACHE = appdirs.AppDirs('plchat', 'plchat').user_cache_dir +APPDATA = appdirs.AppDirs('plchat', 'plchat').user_data_dir +THREADS = {} +STATIC_PREF = '' +ICON_PATH = 'fedi.svg' + +class App(QMainWindow): + settings = QSettings() + _eventloop = asyncio.new_event_loop() + accts = {} + + def __init__(self, processEvents): + super().__init__() + self.title = 'PlChat' + self.processEvents = processEvents + self.initUI() + + def initUI(self): + self.setWindowTitle(self.title) + self.setWindowIcon(QIcon(QPixmap(ICON_PATH))) + self.setGeometry(self.settings.value('left', type=int) or 10, self.settings.value('top', type=int) or 10, self.settings.value('width', type=int) or 640, self.settings.value('height', type=int) or 480) + + self._exit = False + self.Err = QErrorMessage() + + exitAction = QAction('&Exit', self) + exitAction.setShortcut('Ctrl+Q') + exitAction.setToolTip('Exit application') + exitAction.triggered.connect(self.exitActionTrigger) + + newChatAction = QAction('New Chat', self, triggered=self.newChatDialog) + newChatAction.setShortcut('Ctrl+N') + newChatAction.setToolTip('Add a new chat') + + closeChatAction = QAction('Close Chat', self, triggered=self.closeTab) + closeChatAction.setShortcut('Ctrl+W') + closeChatAction.setToolTip('Close the current chat tab') + + newAcctAction = QAction('New Account', self, triggered=self.newAcctDialog) + newAcctAction.setShortcut('Ctrl+Shift+N') + newAcctAction.setToolTip('Add a new account') + + logoutAction = QAction('Logout', self, triggered=self.logout) + logoutAction.setToolTip('Log out of the current account') + + reopenAction = QAction('Reopen All Chats', self, triggered=self.reopenAll) + reopenAction.setToolTip('In case something breaks') + + contactAction = QAction('Contact', self, triggered=self.contactDialog) + licenseAction = QAction('License', self, triggered=self.licenseDialog) + + self.closeToTrayAction = QAction('Close To System Tray', self, checkable=True) + self.closeToTrayAction.setChecked(self.settings.value('closeToTray', type=bool)) + + self.openInTrayAction = QAction('Open In System Tray', self, checkable=True) + self.openInTrayAction.setChecked(self.settings.value('openInTray', type=bool)) + self.openInTrayAction.setToolTip('Does nothing if not also closing to tray.') + + self.animatePicturesAction = QAction('Display Animations', self, checkable=True) + self.animatePicturesAction.setChecked(self.settings.value('animatePictures', type=bool)) + self.animatePicturesAction.changed.connect(updateAnimationPref) + + self.fetchBackgroundsAction = QAction("Fetch Backgrounds", self, checkable=True) + self.fetchBackgroundsAction.setChecked(self.settings.value('fetchBackgrounds', type=bool)) + self.fetchBackgroundsAction.setToolTip("Most instances do not set this in a way that PlChat can check for. It will (probably) not work like you expect, but there is nothing I can do about it.") + + self.darkModeAction = QAction("Dark Mode", self, checkable=True) + self.darkModeAction.setChecked(self.settings.value('darkMode', type=bool)) + self.darkModeAction.setToolTip("Only affects chat bubbles") + + self.sendNotificationsAction = QAction("Send Notifications", self, checkable=True) + self.sendNotificationsAction.setChecked(self.settings.value('sendNotifications', type=bool)) + + self.fetchHeadersAction = QAction("Fetch Headers", self, checkable=True) + self.fetchHeadersAction.setChecked(self.settings.value('fetchHeaders', type=bool)) + + self.twoFourTimeAction = QAction("Display 24-Hour Time", self, checkable=True) + self.twoFourTimeAction.setChecked(self.settings.value('twoFourTime', type=bool)) + + self.acctComboBox = QComboBox(self) + self.acctComboBox.currentIndexChanged.connect(self.acctSwitch) + acctComboBoxAction = QWidgetAction(self) + acctComboBoxAction.setDefaultWidget(self.acctComboBox) + self.acctComboBox.show() + + menubar = self.menuBar() + + if self.closeToTrayAction.isChecked(): + systraymenu = QMenu() + + hideAction = QAction("Hide", self, triggered=self.hide) + showAction = QAction("Show", self, triggered=self.show) + + systraymenu.addAction(showAction) + systraymenu.addAction(hideAction) + systraymenu.addAction(exitAction) + + self.trayIcon = QSystemTrayIcon() + self.trayIcon.setIcon(QIcon(ICON_PATH)) + self.trayIcon.setVisible(True) + self.trayIcon.setToolTip("PlChat") + self.trayIcon.setContextMenu(systraymenu) + self.trayIcon.activated.connect(self.systrayClicked) + else: + self.trayIcon = None + + filemenu = menubar.addMenu("File") + filemenu.addAction(newChatAction) + filemenu.addAction(closeChatAction) + filemenu.addAction(newAcctAction) + filemenu.addAction(exitAction) + + editmenu = menubar.addMenu("Edit") + editmenu.addAction(reopenAction) + editmenu.setToolTipsVisible(True) + + prefsmenu = editmenu.addMenu("Preferences") + prefsmenu.addAction(self.closeToTrayAction) + prefsmenu.addAction(self.openInTrayAction) + prefsmenu.addAction(self.animatePicturesAction) + prefsmenu.addAction(self.darkModeAction) + prefsmenu.addAction(self.sendNotificationsAction) + prefsmenu.addAction(self.fetchBackgroundsAction) + prefsmenu.addAction(self.fetchHeadersAction) + prefsmenu.addAction(self.twoFourTimeAction) + prefsmenu.setToolTipsVisible(True) + + accountmenu = menubar.addMenu('Accounts') + accountmenu.addAction(acctComboBoxAction) + accountmenu.addSeparator() + accountmenu.addAction(logoutAction) + + aboutmenu = menubar.addMenu('About') + aboutmenu.addAction(contactAction) + aboutmenu.addAction(licenseAction) + + self.tabs = QTabWidget() + self.tabs.setMovable(True) + self.tabs.setTabsClosable(True) + self.tabs.tabCloseRequested.connect(self.closeTab) + self.tabs.currentChanged.connect(self.changeTab) + self.setFocusProxy(self.tabs) + + self.setCentralWidget(self.tabs) + + if not self.openInTrayAction.isChecked() or not self.closeToTrayAction.isChecked(): + self.show() + + self.HeaderFont = QFont() + self.HeaderFont.setPointSize(16) + self.headerEmojiFontSize = QFontMetrics(self.HeaderFont).height() + + self.defaultFontMetrics = QFontMetrics(QFont()) + self.emojiFontSize = self.defaultFontMetrics.height() + + self.TimestampFont = QFont() + self.TimestampFont.setPointSize(7) + + self.installEventFilter(self) + + asyncio.set_event_loop(self._eventloop) + self._eventloop.call_soon(self.eventLoop) + + acctList = self.settings.value('acctList', type=list) + if not acctList: + self.newAcctDialog() + return + for acct in acctList: + CallThread(getAvi, None, acct['instance']) + self.initAcct(acct['instance'], acct['username']) + + def systrayClicked(self, reason): + if reason == QSystemTrayIcon.Trigger: + if self.isVisible(): + self.hide() + else: + self.show() + + def eventLoop(self): + # Custom event loop to process queue events + self.processEvents() + self._eventloop.call_soon(self.eventLoop) + monkeypatch.sleep(0.01) + + def exitActionTrigger(self): + self._exit = True + self.close() + + def closeEvent(self, event): + if not self._exit and self.closeToTrayAction.isChecked(): + self.hide() + return + self.settings.setValue("left", self.x()) + self.settings.setValue("top", self.y()) + self.settings.setValue("width", self.width()) + self.settings.setValue("height", self.height()) + self.settings.setValue("closeToTray", self.closeToTrayAction.isChecked()) + self.settings.setValue("openInTray", self.openInTrayAction.isChecked()) + self.settings.setValue("animatePictures", self.animatePicturesAction.isChecked()) + self.settings.setValue("darkMode", self.darkModeAction.isChecked()) + self.settings.setValue("fetchBackgrounds", self.fetchBackgroundsAction.isChecked()) + self.settings.setValue("sendNotifications", self.sendNotificationsAction.isChecked()) + self.settings.setValue("fetchHeaders", self.fetchHeadersAction.isChecked()) + self.settings.setValue("twoFourTime", self.twoFourTimeAction.isChecked()) + event.accept() + self._eventloop.stop() + + def getCurrentAcc(self): + if self.acctComboBox.currentIndex() == -1 or not self.accts: + return (False, False) + # Returns username, instance + return self.acctComboBox.currentText().split('@')[1], self.acctComboBox.currentText().split('@')[2] + + def newAcctDialog(self): + dialog = LoginDialog(self) + dialog.getInput(self.initAcct) + + def newChatDialog(self): + u, i = self.getCurrentAcc() + text, ok = QInputDialog.getText(self, 'Start A New Chat', "Username:", QLineEdit.Normal, "") + if ok: + if len(text) < 1: + self.Err.showMessage("No text provided") + else: + if text[0] == '@': + text = text[1:] + if text.find('@'+i) != -1: + text = text.replace('@'+i, '') + CallThread(self.accts[u+i].getAcctInfo, self.newChatReady, text).start() + + def newChatReady(self, result): + if result is None: + self.Err.showMessage("I couldn't find that user!") + elif not result['pleroma']['accepts_chat_messages']: + self.Err.showMessage("This user is on instance that does not support pleroma chats.\nIt may be too old, or it may not be pleroma.") + else: + u, i = self.getCurrentAcc() + closedList = self.settings.value('closed'+u+i, type=list) or [] + for ind in range(0,(len(closedList))): + if closedList[ind] == result['acct']: + del closedList[ind] + break + self.settings.setValue('closed'+u+i, closedList) + CallThread(self.accts[u+i].addChat, self.populateChats, result['id']).start() + + def contactDialog(self): + dialog = ContactCard(self) + dialog.show() + + def licenseDialog(self): + dialog = LicenseCard(self) + dialog.show() + + def reopenAll(self): + u, i = self.getCurrentAcc() + if not u or not i: + return + self.settings.setValue('closed'+u+i, []) + CallThread(self.accts[u+i].listChats, self.populateChats).start() + + def initAcct(self, instance, username, password=None): + if password: + acct = pleroma.Account(instance, username, password) + else: + token = keyring.get_password('plchat', instance+username+'access_token') + refresh_token = keyring.get_password('plchat', instance+username+'refresh_token') + clientID = keyring.get_password('plchat', instance+username+'clientID') + clientSecret = keyring.get_password('plchat', instance+username+'clientSecret') + acct = pleroma.Account(instance, username, + token=token, + refresh_token=refresh_token, + clientID=clientID, + clientSecret=clientSecret + ) + RegisterThread(acct, self.doneRegister).start() + + def doneRegister(self, acct): + self.accts[acct.username+acct.instance] = acct + self.acctComboBox.addItem('@'+acct.username+'@'+acct.instance) + acctList = self.settings.value('acctList', type=list) or [] + concat = True + for acc in acctList: + if acc['username'] == acct.username and acc['instance'] == acct.instance: + concat = False + if concat: + acctList.append({"username": acct.username, "instance": acct.instance}) + self.settings.setValue('acctList', acctList) + + keyring.set_password('plchat', acct.instance+acct.username+'access_token', acct.token) + keyring.set_password('plchat', acct.instance+acct.username+'refresh_token', acct.refresh_token) + keyring.set_password('plchat', acct.instance+acct.username+'clientID', acct.clientID) + keyring.set_password('plchat', acct.instance+acct.username+'clientSecret', acct.clientSecret) + + CallThread(getAvi, None, acct.instance).start() + CallThread(acct.getInstanceInfo, None).start() + + def logout(self): + u, i = self.getCurrentAcc() + if not u or not i: + print("Couldn't log out") + return + self.acctComboBox.removeItem(self.acctComboBox.currentIndex()) + del self.accts[u+i] + acctList = self.settings.value('acctList', type=list) + for j in range(len(acctList)): + if acctList[j]['username'] == u and acctList[j]['instance'] == i: + keyring.delete_password("plchat", i+u+'access_token') + keyring.delete_password("plchat", i+u+'refresh_token') + keyring.delete_password("plchat", i+u+'clientID') + keyring.delete_password("plchat", i+u+'clientSecret') + del acctList[j] + self.settings.setValue('acctList', acctList) + + def acctSwitch(self): + u, i = self.getCurrentAcc() + if u+i in self.accts and 'title' in self.accts[u+i].getInstanceInfo(): + self.setWindowTitle(self.accts[u+i].getInstanceInfo()['title']+' Chat') + else: + self.setWindowTitle('PlChat') + self.tabs.clear() + CallThread(self.accts[u+i].listChats, self.populateChats).start() + + def populateChats(self, chatList): + if type(chatList) == dict: + chatList = [chatList] + u, i = self.getCurrentAcc() + if not u or not i: + return + closedList = self.settings.value('closed'+u+i, type=list) or [] + for chat in chatList: + c = False + for ind in range(0, self.tabs.count()): + if self.tabs.widget(ind).acct == chat['account']['acct']: + c = True + for entry in closedList: + if chat['account']['acct'] == entry: + c = True + if c: + continue + ctab = ChatArea(chat, self.tabs) + self.tabs.addTab(ctab, chat['account']['display_name']) + + def closeTab(self, index=None): + if not index: + index = self.tabs.currentIndex() + act = self.tabs.widget(index).acct + u, i = self.getCurrentAcc() + closedList = self.settings.value('closed'+u+i, type=list) or [] + f = True + for entry in closedList: + if entry == act: + f = False + if f: + closedList.append(act) + self.settings.setValue('closed'+u+i, closedList) + self.tabs.removeTab(index) + + def handlePleromaEvent(self, acct, event): + if event['event'] == 'pleroma:chat_update': + payload = json.loads(event['payload']) + tmp = 0 + for ind in range(0, self.tabs.count()): + if self.tabs.widget(ind).acct == payload['account']['acct']: + self._eventloop.call_soon_threadsafe(self.tabs.widget(ind).addMessage, payload['last_message']) + tmp = ind + #self.tabs.widget(ind).addMessage(payload['last_message']) + if payload['last_message']['account_id'] != acct.flakeid and (not self.hasFocus() or payload['account']['acct'] != self.tabs.widget(self.tabs.currentIndex()).acct): + if self.sendNotificationsAction.isChecked(): + CallThread(self.makeNotification, None, payload['last_message']['content'], payload['account']['acct'], payload['account']['avatar_static']).start() + app.alert(self, 0) + self._eventloop.call_soon_threadsafe(self.setUrgent, tmp) + + def makeNotification(self, content, user, url): + path = getPic(url) + sendNotification(content, title=user, icon=path) + + def setUrgent(self, ind): + icon = QIcon() + icon.addPixmap(QPixmap('unread.svg')) + self.tabs.setTabIcon(ind, icon) + self.tabs.widget(ind).unread = True + if self.trayIcon: + self.trayIcon.setIcon(icon) + self.setWindowIcon(icon) + + def changeTab(self, ind): + if not self.tabs.widget(ind): + return + if self.tabs.widget(ind).unread: + CallThread(getPic, self.tabs.widget(ind).useAvi, self.tabs.widget(ind).avaURL).start() + self.tabs.widget(ind).unread = False + flip = True + for A in range(0, self.tabs.count()): + if self.tabs.widget(A).unread: + flip = False + if flip: + if self.trayIcon: + self.trayIcon.setIcon(QIcon(QPixmap(ICON_PATH))) + self.setWindowIcon(QIcon(QPixmap(ICON_PATH))) + self.tabs.widget(ind).markRead() + self.tabs.widget(ind).setFocus(Qt.NoFocusReason) + + + def eventFilter(self, object, event): + if event.type() == QEvent.WindowActivate: + self.windowActivate() + return False + + def windowActivate(self): + ind = self.tabs.currentIndex() + if not self.tabs.widget(ind): + return + if self.tabs.widget(ind).unread: + CallThread(getPic, self.tabs.widget(ind).useAvi, self.tabs.widget(ind).avaURL).start() + self.tabs.widget(ind).unread = False + flip = True + for A in range(0, self.tabs.count()): + if self.tabs.widget(A).unread: + flip = False + if flip: + if self.trayIcon: + self.trayIcon.setIcon(QIcon(QPixmap(ICON_PATH))) + self.setWindowIcon(QIcon(QPixmap(ICON_PATH))) + self.tabs.widget(ind).markRead() + self.tabs.widget(ind).setFocus(Qt.NoFocusReason) + +class BGWidget(QWidget): + def __init__(self, path=None): + super().__init__() + self.path=path + self.pixmap = QPixmap() + + def setPath(self, path): + self.path = path + + def paintEvent(self, event): + super().paintEvent(event) + if self.pixmap.isNull() and self.path: + self.pixmap = QPixmap(self.path) + painter = QPainter(self) + if not self.pixmap.isNull(): + self.pixmap = self.pixmap.scaled(self.width(), self.height(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + painter.drawPixmap(0,0, self.pixmap) + +class ChatArea(QWidget): + def __init__(self, chat, parent=None): + super().__init__(parent=parent) + self.acct = chat['account']['acct'] + self.parent = parent + self.chatID = chat['id'] + self.account = chat['account'] + self.unread = False + if ex.animatePicturesAction.isChecked(): + avatar_prop = 'avatar' + header_prop = 'header' + else: + avatar_prop = 'avatar_static' + header_prop = 'header_static' + self.avaURL = chat['account'][avatar_prop] + CallThread(getPic, self.useAvi, self.avaURL).start() + if ex.fetchHeadersAction.isChecked(): + CallThread(getPic, self.headerReady, chat['account'][header_prop]).start() + + self.wrapper = BGWidget() + wrapperlayout = QHBoxLayout() + wrapperlayout.setContentsMargins(0,0,0,0) + wrapperlayout.setSpacing(0) + self.wrapper.setLayout(wrapperlayout) + + self.header = QFrame() + headerlayout = QHBoxLayout() + headernest = QVBoxLayout() + headernestwidget = QWidget() + headernestwidget.setContentsMargins(0,0,0,0) + self.header.setContentsMargins(0,0,0,0) + + wrapperlayout.addWidget(self.header) + + self.header.setLayout(headerlayout) + headernestwidget.setLayout(headernest) + + uDN = EmojiText(chat['account']['display_name'], chat['account']['emojis'], header=True) + if self.acct.find('@') == -1: + uACT = LinkLabel(chat['account']['url'], '@'+self.acct+'@'+ex.getCurrentAcc()[1]) + else: + uACT = LinkLabel(chat['account']['url'], '@'+self.acct) + uACT.setMargin(0) + #uACT.setStyleSheet("color: white;") + headernest.addWidget(uDN) + headernest.addWidget(uACT) + + self.avatar = QLabel() + self.avatar.setMargin(0) + self.avatar.setFixedHeight(round(QDesktopWidget().screenGeometry(-1).height() * 0.06)) + + headerlayout.addWidget(self.avatar) + headerlayout.addWidget(headernestwidget, 100) + + self.messageArea = MessageArea(self.chatID, self.account, self.callMe) + self.msgs = MovingScroll(self.messageArea) + + if ex.fetchBackgroundsAction.isChecked(): + if self.acct.find('@') == -1: + remoteURL = ex.getCurrentAcc()[1] + else: + remoteURL = self.acct.split('@')[1] + self.msgs.remoteURL = remoteURL + CallThread(requests.get, self.msgs.parseNodeInfo, 'https://'+remoteURL+'/api/v1/instance').start() + del remoteURL + + self.msgs.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.msgs.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + self.msgs.setWidgetResizable(True) + self.messageArea.setFixedWidth(self.msgs.getChildWidth()) + self.messageArea.update() + + send = SendArea(self.chatID) + + layout = QVBoxLayout() + layout.addWidget(self.wrapper, 2) + layout.addWidget(self.msgs, 35) + layout.addWidget(send, 1) + + self.setFocusProxy(send.getTextbox()) + self.setLayout(layout) + + def callMe(self): + self.msgs.setWidget(self.messageArea) + + def markRead(self): + self.messageArea.markRead() + + def headerReady(self, path): + self.wrapper.setPath(path) + + def addMessage(self, message): + self.messageArea.messages.insert(0, message) + self.messageArea._update(self.messageArea.messages) + + def useAvi(self, path): + pic = QPixmap(path) + ava = QPixmap(path) + if pic.isNull(): + pic = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') + ava = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') + + icon = QIcon() + icon.addPixmap(pic) + self.parent.setTabIcon(self.parent.indexOf(self), icon) + + ava = ava.scaledToHeight(round(QDesktopWidget().screenGeometry(-1).height() * 0.06), mode=Qt.SmoothTransformation) + self.avatar.resize(ava.width(), ava.height()) + self.avatar.setPixmap(ava) + self.avatar.setFixedWidth(self.avatar.width()) + + def paintEvent(self, event): + super().paintEvent(event) + if not self.wrapper.pixmap.isNull(): + self.header.setStyleSheet(".QFrame{background-color: rgba(0, 0, 0, 0.3);} QFrame{color: white;}") + self.paintEvent = super().paintEvent + + +class MovingScroll(QScrollArea): + def __init__(self, child, path=None): + super().__init__() + self.child = child + self.remoteURL = '' + self.verticalScrollBar().rangeChanged.connect(self.scrollToBottom) + self.verticalScrollBar().valueChanged.connect(self.handleValue) + + def resizeEvent(self, event): + self.child.setFixedWidth(self.getChildWidth()) + + def scrollToBottom(self): + self.verticalScrollBar().triggerAction(QAbstractSlider.SliderToMaximum) + + def getChildWidth(self): + return self.width()-self.verticalScrollBar().width()-5 + + def handleValue(self, pos): + if pos < (self.verticalScrollBar().maximum() / 10): + self.child.addPage() + + def parseNodeInfo(self, response): + try: + CallThread(getPic, self.child.setPath, 'https://'+self.remoteURL+response.json()['background_image']).start() + except: + print("Couldn't download background image for: "+remoteURL) + +class MessageArea(QWidget): + def __init__(self, chatID, account, callback): + super().__init__() + self.chatID = chatID + self.account = account + self.callback = callback + self.path = None + self.pixmap = QPixmap() + self.layout = QVBoxLayout() + self.layout.setContentsMargins(10, 0,-5,0) + self.last_read_id = None + self.messages = [] + self.page = 0 + self.fetchingPage = False + + def update(self): + u, i = ex.getCurrentAcc() + CallThread(ex.accts[u+i].getMessages, self._update, self.chatID).start() + + def markRead(self): + if not self.last_read_id: + return + u, i = ex.getCurrentAcc() + acc = ex.accts[u+i] + acc.markChatRead(self.chatID, self.last_read_id) + + def addPage(self): + if self.fetchingPage: + return + self.fetchingPage = True + u, i = ex.getCurrentAcc() + self.page += 1 + CallThread(ex.accts[u+i].getMessages, self.pageReady, self.chatID, past=self.messages[len(self.messages)-1]['id']).start() + + def pageReady(self, messages): + for message in messages: + self.messages.append(message) + self._update(self.messages) + + def _update(self, messages): + self.messages = messages + for message in messages: + if message['account_id'] == self.account['id']: + self.last_read_id = message['id'] + for i in reversed(range(self.layout.count())): + self.layout.itemAt(i).widget().setParent(None) + for message in reversed(messages): + #for line in re.split(newlineRegex, message['content']): + # if line.strip() == '': + # continue + self.layout.addWidget(SingleMessage(message, self.account, self), 0, Qt.AlignBottom) + self.setLayout(self.layout) + self.callback() + if self.fetchingPage: + self.fetchingPage = False + + def getMessages(self): + return self.messages or [] + + def setPath(self, path): + self.path = path + + def paintEvent(self, event): + super().paintEvent(event) + if self.pixmap.isNull() and self.path: + self.pixmap = QPixmap(self.path) + painter = QPainter(self) + if not self.pixmap.isNull(): + self.pixmap = self.pixmap.scaled(self.width(), self.height(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + painter.drawPixmap(0,0, self.pixmap) + +newlineRegex = re.compile('<\/?br\/?>|\n') + +class SingleMessage(QWidget): + def __init__(self, message, account, parent): + super().__init__(parent=parent) + self.parent = parent + self.message = message + self.account = account + self.layout = QHBoxLayout() + self.layout.setContentsMargins(0,0,0,0) + self.layout.setSpacing(0) + self.setStyleSheet("MessageAvatar{margin: 0em 0.2em 0em 0.2em;border: 1px solid black;}") + + if ex.animatePicturesAction.isChecked(): + avatar_prop = 'avatar' + header_prop = 'header' + else: + avatar_prop = 'avatar_static' + header_prop = 'header_static' + self.avaURL = self.account[avatar_prop] + + u,i = ex.getCurrentAcc() + self.userPixmap = None + self.convoPixmap = None + CallThread(getPic, self.setUserPixmap, ex.accts[u+i].acct[avatar_prop]).start() + CallThread(getPic, self.setConvoPixmap, self.avaURL).start() + + if self.message['account_id'] == self.account['id']: + self.layout.addWidget(MessageAvatar('convo', self), 0, (Qt.AlignLeft | Qt.AlignTop)) + self.layout.addWidget(InternalMessage(self.message, self.account, self), 1, Qt.AlignLeft) + #spacer = QWidget() + #spacer.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) + #self.layout.addWidget(spacer, 9000) + else: + #spacer = QWidget() + #spacer.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) + #self.layout.addWidget(spacer, 9000) + self.layout.addWidget(InternalMessage(self.message, self.account, self), 1, Qt.AlignRight) + self.layout.addWidget(MessageAvatar('user', self), 0, (Qt.AlignRight | Qt.AlignTop)) + + self.setLayout(self.layout) + + def setUserPixmap(self, path): + p = QPixmap(path) + if p.isNull(): + p = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') + p = p.scaledToHeight(50, mode=Qt.SmoothTransformation) + self.userPixmap = p + def setConvoPixmap(self, path): + p = QPixmap(path) + if p.isNull(): + p = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') + p = p.scaledToHeight(50, mode=Qt.SmoothTransformation) + self.convoPixmap = p + +class MessageAvatar(QLabel): + def __init__(self, futurepixmap, parent): + super().__init__(parent=parent) + self.parent = parent + self.pixmap = QPixmap() + self.setMargin(0) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + CallThread(self.awaitPixmap, None, futurepixmap).start() + #self.setPixmap(self.pixmap) + + def setPath(self, path): + #if not self.pixmap.isNull(): + # return + #self.pixmap = QPixmap(path) + #if self.pixmap.isNull(): + # self.pixmap = QPixmap(APPDATA+'/'+ex.getCurrentAcc()[1]+'avi.png') + #self.pixmap = self.pixmap.scaledToHeight(50, mode=Qt.SmoothTransformation) + self.pixmap = path + self.resize(self.pixmap.width(), self.pixmap.height()) + self.setPixmap(self.pixmap) + self.setFixedWidth(self.pixmap.width()) + + def awaitPixmap(self, p): + if p == 'user': + while not self.parent.userPixmap: + monkeypatch.sleep(0.2) + self.resize(self.parent.userPixmap.width(), self.parent.userPixmap.height()) + self.setPixmap(self.parent.userPixmap) + self.setFixedWidth(self.parent.userPixmap.width()) + else: + while not self.parent.convoPixmap: + monkeypatch.sleep(0.2) + self.resize(self.parent.convoPixmap.width(), self.parent.convoPixmap.height()) + self.setPixmap(self.parent.convoPixmap) + self.setFixedWidth(self.parent.convoPixmap.width()) + +class InternalMessage(QFrame): + def __init__(self, message, account, parent): + super().__init__(parent=parent) + self.parent = parent + self.message = message + self.account = account + self.layout = QHBoxLayout() + self.layout.setContentsMargins(0,0,0,0) + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) + self.attachment = None + if ex.darkModeAction.isChecked(): + color_text="#ffffff" + color_bg = "#000000" + else: + color_text="#000000" + color_bg = "#ffffff" + + self.setStyleSheet("InternalMessage{background-color: "+color_bg+";border-radius: 1em;padding: 0.5em;border: 1px solid "+color_text+";} QWidget{color:"+color_text+";}") + + if self.message['content']: + self.label = EmojiText(self.message['content'], self.message['emojis']) + self.label.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) + else: + self.label = None + + self.timestamp = QLabel(timeconvert.utc_to_local(self.message['created_at'], ex.twoFourTimeAction.isChecked())) + self.timestamp.setFont(ex.TimestampFont) + self.timestamp.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + if self.label: + self.layout.addWidget(self.label) + if not self.message['attachment']: + self.layout.addWidget(self.timestamp, 1, (Qt.AlignRight | Qt.AlignBottom)) + else: + CallThread(self.getMime, self.addAttachment, self.message['attachment']['url']).start() + + #self.setMinimumSize(QSize(50, computedHeight)) + self.setLayout(self.layout) + + def getMime(self, url): + with urllib.request.urlopen(url) as res: + # pleroma sux balls at mime types + mime = magic.from_buffer(res.read(2048), mime=True) + return mime + + def addAttachment(self, mime): + self.attachment = MediaAttachment(self.message['attachment'], mime) + self.layout.addWidget(self.attachment) + self.layout.addWidget(self.timestamp, 1, (Qt.AlignRight | Qt.AlignBottom)) + + def sizeHint(self): + multiplier = 2 + additive = 0 + if self.label: + multiplier += self.label.layoutfinal.count() + if self.attachment: + additive += self.attachment.getMedia().height() + computedHeight = (ex.defaultFontMetrics.height() * multiplier) + additive + + computedWidth = 50 + if self.attachment: + if self.attachment.getMedia().width() > computedWidth: + computedWidth = self.attachment.getMedia().width() + return QSize(computedWidth, computedHeight) + +def MediaAttachment(media, mime): + if mime.find("image/") != -1: + return ImageAttachment(media) + elif mime.find("video/") != -1: + return VideoAttachment(media) + elif mime.find("audio/") != -1: + return AudioAttachmentWrapper(media) + else: + return FileAttachment(media) + +class FileAttachment(QPushButton): + def __init__(self, media): + super(FileAttachment, self).__init__() + self.url = QUrl(media['url']) + self.setCursor(Qt.PointingHandCursor) + self.setIcon(self.style().standardIcon(QStyle.SP_FileLinkIcon)) + self.setFlat(True) + + def mouseReleaseEvent(self, event): + if event.modifiers() == Qt.NoModifier and event.button() == Qt.LeftButton: + QDesktopServices.openUrl(self.url) + + def getMedia(self): + return self + +class AudioAttachmentWrapper(QWidget): + def __init__(self, media): + super(AudioAttachmentWrapper, self).__init__() + layout = QHBoxLayout() + self.file = FileAttachment(media) + self.audio = AudioAttachment(media) + layout.addWidget(self.file) + layout.addWidget(self.audio) + self.setLayout(layout) + + def getMedia(self): + return QSize(self.file.getMedia().width()+self.audio.getMedia().width(), self.file.getMedia().height()+self.audio.getMedia().height()) + +class AudioAttachment(audiowidget.AudioPlayer): + def __init__(self, media): + super(AudioAttachment, self).__init__() + #CallThread(self.openFile, None, media['url']).start() + self.openFile(media['url']) + + def getMedia(self): + return super().sizeHint() + +class LinkLabel(QLabel): + def __init__(self, url, *args, **kwargs): + super(LinkLabel, self).__init__(*args, **kwargs) + self.url = QUrl(url) + self.setCursor(Qt.PointingHandCursor) + def mouseReleaseEvent(self, event): + bttn = event.button() + modif = event.modifiers() + if modif == Qt.NoModifier and bttn == Qt.LeftButton: + QDesktopServices.openUrl(self.url) + +class VideoAttachment(videowidget.VideoPlayer): + def __init__(self, media, *args, **kwargs): + super().__init__(media['url'], *args, **kwargs) + #CallThread(self.openFile, None, media['url']).start() + self.openFile(media['url']) + + def getMedia(self): + return QSize(self.width(), self.videoWidget.height()+50) + +class ImageAttachment(LinkLabel): + def __init__(self, media): + super().__init__(media['remote_url']) + self.pixmap = QPixmap() + CallThread(getPic, self.setPath, media['url']).start() + + def setPath(self, path): + self.pixmap = QPixmap(path) + if round(ex.width() * 0.7) < self.pixmap.width(): + self.pixmap = self.pixmap.scaledToWidth(round(ex.width() * 0.7), mode=Qt.SmoothTransformation) + self.resize(self.pixmap.width(), self.pixmap.height()) + self.setPixmap(self.pixmap) + #self.setFixedHeight(self.height()) + self.url = QUrl(path) + + def getMedia(self): + return self.pixmap + +class SendArea(QWidget): + def __init__(self, chatID): + super().__init__() + self.layout = QHBoxLayout() + self.lastFile = None + self.mediaPreview = None + self.mediaID = None + self.Err = QErrorMessage() + self.chatID = chatID + + self.textbox = TextBox() + + self.mediaStack = QStackedWidget() + + self.attachButton = QPushButton(self.style().standardIcon(QStyle.SP_FileDialogStart), 'Attach File') + self.attachButton.clicked.connect(self.newFileDialog) + self.attachButton.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + + sendButton = QPushButton(QIcon(QPixmap('send.svg')), '') + sendButton.clicked.connect(self.sendMessage) + sendButton.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + + self.mediaStack.addWidget(self.attachButton) + self.mediaStack.setCurrentWidget(self.attachButton) + + self.layout.addWidget(self.textbox, 8) + self.layout.addWidget(self.mediaStack, 1) + self.layout.addWidget(sendButton, 1) + self.setFocusProxy(self.textbox) + self.setLayout(self.layout) + self.textbox.setSendFunction(self.sendMessage) + + def getTextbox(self): + return self.textbox + + def newFileDialog(self): + if self.mediaPreview == 'uploading': + self.Err.showMessage("Already uploading a file!") + return + qfd = QFileDialog(self) + qfd.setFileMode(QFileDialog.ExistingFile) + qfd.setNameFilters(["All Files (*)"]) + qfd.filesSelected.connect(self.attachFile) + qfd.show() + + def attachFile(self, paths): + if paths[0] == self.lastFile: + return + elif self.mediaPreview: + self.Err.showMessage("File already attached!") + return + u, i = ex.getCurrentAcc() + CallThread(ex.accts[u+i].uploadMedia, self.doneAttachFile, paths[0]).start() + self.mediaPreview = 'uploading' + self.lastFile = paths[0] + + def doneAttachFile(self, media): + if not 'id' in media: + self.Err.showMessage('Error uploading file') + self.detachFile() + return + self.mediaID = media['id'] + self.mediaPreview = QPushButton(ex.style().standardIcon(QStyle.SP_FileIcon), '') + self.mediaPreview.clicked.connect(self.detachDialog) + self.mediaPreview.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) + self.mediaStack.addWidget(self.mediaPreview) + self.mediaStack.setCurrentWidget(self.mediaPreview) + + def detachDialog(self): + dialog = DetachDialog() + dialog.accepted.connect(self.detachFile) + dialog.getInput() + + def detachFile(self): + if self.mediaPreview and type(self.mediaPreview) != str: + self.mediaStack.removeWidget(self.mediaPreview) + self.mediaStack.setCurrentWidget(self.attachButton) + self.mediaPreview = None + self.lastFile = None + self.mediaID = None + + def sendMessage(self): + u, i = ex.getCurrentAcc() + acc = ex.accts[u+i] + if self.textbox.toPlainText() == '': + text = None + else: + text = self.textbox.toPlainText() + CallThread(acc.sendMessage, None, self.chatID, content=text, media=self.mediaID).start() + self.textbox.setText('') + if self.mediaPreview: + self.detachFile() + self.textbox.setFocus(Qt.NoFocusReason) + +class TextBox(QTextEdit): + def __init__(self, sendFunction=None): + super().__init__() + self.sendFunction = sendFunction or self.noop + + def keyPressEvent(self, event): + if not event.matches(QKeySequence.InsertParagraphSeparator): + super().keyPressEvent(event) + else: + self.sendFunction() + + def setSendFunction(self, sendFunction): + self.sendFunction = sendFunction + + def noop(self): + return + +class DetachDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle("Detach Dialog") + + QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.layout = QVBoxLayout() + self.message = QLabel("Detach this file?") + self.layout.addWidget(self.message) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + def getInput(self): + self.exec_() + self.show() + self.raise_() + self.activateWindow() + +class LoginDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle("Log In") + + QBtn = QDialogButtonBox.Ok | QDialogButtonBox.Cancel + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.layout = QVBoxLayout() + self.message = QLabel("Log in to your fediverse account") + self.username = QLineEdit() + self.username.setPlaceholderText('Username (e.g. Moon)') + self.instance = QLineEdit() + self.instance.setPlaceholderText('Instance (e.g. shitposter.club)') + self.password = QLineEdit() + self.password.setPlaceholderText('Password') + self.password.setEchoMode(QLineEdit.Password) + self.layout.addWidget(self.message) + self.layout.addWidget(self.username) + self.layout.addWidget(self.instance) + self.layout.addWidget(self.password) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + self.setFocusProxy(self.username) + + def getInput(self, func): + self.show() + self.raise_() + self.activateWindow() + self.accepted.connect(self._finished) + self.finishedFunc = func + + def _finished(self): + instance = self.instance.text() + instance = instance.replace('http://', '') + instance = instance.replace('https://', '') + if instance[len(instance) - 1] == '/': + instance = instance[0:(len(instance) - 2)] + self.finishedFunc(self.instance.text(), self.username.text(), password=self.password.text()) + +class ContactCard(QDialog): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle("Contact Info") + + QBtn = QDialogButtonBox.Ok + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + + self.layout = QVBoxLayout() + self.message = QLabel("Written by knotteye\nContact me in the following places (in order of preference) with issues") + self.fedi = QLabel("Fedi: @knotteye@waldn.net") + self.fedi.setOpenExternalLinks(True) + self.xmpp = QLabel('XMPP: knotteye@telekem.net') + self.xmpp.setOpenExternalLinks(True) + self.email = QLabel('Email: knotteye@airmail.cc') + self.email.setOpenExternalLinks(True) + self.layout.addWidget(self.message) + self.layout.addWidget(self.fedi) + self.layout.addWidget(self.xmpp) + self.layout.addWidget(self.email) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + self.show() + self.raise_() + self.activateWindow() + +class LicenseCard(QDialog): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setWindowTitle("Required Licensing Information") + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) + + QBtn = QDialogButtonBox.Ok + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + + self.layout = QVBoxLayout() + self.tabs = QTabWidget() + + plchatlayout = QVBoxLayout() + plchatW = QWidget() + plchatW.setLayout(plchatlayout) + plchatlabel = QLabel("Repository") + plchatlabel.setTextFormat(Qt.RichText) + plchatlabel.setOpenExternalLinks(True) + plchatLicense = QLabel('butts\nbutts\nbutts\nbutts\nbutts\nbutts\nbutts\nbutts\nbutts\nbutts\nbutts\nbutts\n') + plchatscroll = QScrollArea() + plchatscroll.setWidget(plchatLicense) + plchatlayout.addWidget(plchatlabel) + plchatlayout.addWidget(plchatscroll) + + pyqtlayout = QVBoxLayout() + pyqtW = QWidget() + pyqtW.setLayout(pyqtlayout) + pyqtlabel = QLabel("More Information") + pyqtlabel.setTextFormat(Qt.RichText) + pyqtlabel.setOpenExternalLinks(True) + pyqtLicense = QLabel(misc.gpltext) + pyqtscroll = QScrollArea() + pyqtscroll.setWidget(pyqtLicense) + pyqtlayout.addWidget(pyqtlabel) + pyqtlayout.addWidget(pyqtscroll) + + videolayout = QVBoxLayout() + videoW = QWidget() + videoW.setLayout(videolayout) + videolicense = QLabel(misc.videowidgetlicense) + videoscroll = QScrollArea() + videoscroll.setWidget(videolicense) + videolayout.addWidget(videoscroll) + + self.tabs.addTab(plchatW, 'PlChat') + self.tabs.addTab(pyqtW, 'PyQt5') + self.tabs.addTab(videoW, "videowidget.py") + + self.layout.addWidget(self.tabs) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + self.show() + self.raise_() + self.activateWindow() + + def sizeHint(self): + return QSize(500, 900) + +class EmojiText(QWidget): + ceRegex = re.compile('(:[^:]+:)') + def __init__(self, text, emojiList, header=False): + super().__init__() + self.layoutfinal = QVBoxLayout() + self.layoutfinal.setSpacing(0) + self.layoutfinal.setContentsMargins(0,0,0,0) + + for line in re.split(newlineRegex, text): + self.layoutfinal.addWidget(self.makeLine(line, emojiList, header)) + + self.setLayout(self.layoutfinal) + + def makeLine(self, text, emojiList, header): + widget = QWidget() + layout = QWidget() + layout = QHBoxLayout() + layout.setSpacing(0) + layout.setContentsMargins(0,0,0,0) + widget.setContentsMargins(0,0,0,0) + customEmojis = [] + if header: + size = ex.headerEmojiFontSize + else: + size = ex.emojiFontSize + + for em in re.findall(self.ceRegex, text): + for emoji in emojiList: + if emoji['shortcode'] == em[1:len(em)-1]: + cemurl = emoji[STATIC_PREF+'url'] + customEmojis.append(CustomEmoji(em[1:len(em)-1], cemurl, size)) + + i = 0 + + for stringpart in re.split(self.ceRegex, text): + if len(stringpart) > 2 and stringpart[0] == ':' and stringpart[len(stringpart)-1] == ':': + layout.addWidget(customEmojis[i], 1) + i += 1 + else: + label = QLabel(stringpart) + if header: + label.setFont(ex.HeaderFont) + label.setTextFormat(Qt.RichText) + label.setTextInteractionFlags((Qt.TextBrowserInteraction)) + label.setOpenExternalLinks(True) + layout.addWidget(label) + + layout.addWidget(QWidget(), 9000) + widget.setLayout(layout) + return widget + +class CustomEmoji(QLabel): + def __init__(self, shortcode, url=None, height=12): + super().__init__() + self.setScaledContents=True + if url: + CallThread(getPic, self.loadPic, url).start() + self.mheight = height + self.pixmap = None + self.setMargin(0) + + def loadPic(self, path): + self.pixmap = QPixmap(path) + self.setPixmap(self.pixmap.scaledToHeight(self.mheight, mode=Qt.SmoothTransformation)) + +class CallThread(threading.Thread): + # Generic thread object that takes some function, a callback and a list of arguments + # The entire purpose of this object is to be a wrapper around running functions in a new thread + def __init__(self, function, callback, *args, **kwargs): + super(CallThread, self).__init__() + self.function = function + self.args = args or () + self.kwargs = kwargs or {} + self.callback = callback + + def run(self): + result = self.function(*self.args, **self.kwargs) + if not self.callback: + return + ex._eventloop.call_soon_threadsafe(self.callback, result) + +class RegisterThread(threading.Thread): + def __init__(self, acct, callback): + super(RegisterThread, self).__init__() + self.acct = acct + self.callback = callback + + def run(self): + self.acct.register() + self.acct.login() + NotifThread(self.acct).start() + self.acct.setChatUpdate(ex.handlePleromaEvent) + self.callback(self.acct) + +class NotifThread(threading.Thread): + def __init__(self, acct): + super(NotifThread, self).__init__() + self.acct = acct + self.loop = asyncio.new_event_loop() + self.daemon = True + asyncio.set_event_loop(self.loop) + + def run(self): + self.loop.run_until_complete(self.acct.startStreaming()) + +class HLine(QFrame): + def __init__(self): + super(HLine, self).__init__() + self.setFrameShape(self.HLine|self.Sunken) + +class VLine(QFrame): + def __init__(self): + super(VLine, self).__init__() + self.setFrameShape(self.VLine|self.Sunken) + +class IconLabel(QLabel): + def __init__(self, icon, w=30, h=None): + super(IconLabel, self).__init__() + if not h: + h = w + #self.icon = self.style().standardIcon(icon) + self.icon = icon + self.setPixmap(self.icon.pixmap(QSize(w, h))) + +Notification = Notify( + default_notification_title="PlChat", + default_notification_icon=ICON_PATH + #default_notification_audio=NOTIF_SOUND +) + +def sendNotification(message, title=None, icon=None): + Notification.message = message + if title: + Notification.title = title + if icon: + Notification.icon = icon + Notification.send(block=False) + +def getPic(url): + filename = urllib.parse.urlparse(url).netloc+os.path.basename(urllib.parse.urlparse(url).path) + filepath = CACHE+'/img/'+filename + try: + f = open(filepath, mode='xb+') + THREADS[filepath] = threading.current_thread() + except FileExistsError: + try: + while THREADS[filepath].is_alive(): + monkeypatch.sleep(0.2) + return filepath + except KeyError: + return filepath + try: + imgdata = urllib.request.urlopen(url).read() + except Exception as E: + print("Got "+str(E)+" while downloading file: "+url) + return filepath + f.write(imgdata) + f.close() + return filepath + +def getVid(url): + filename = urllib.parse.urlparse(url).netloc+os.path.basename(urllib.parse.urlparse(url).path) + filepath = CACHE+'/vid/'+filename + try: + f = open(filepath, mode='xb+') + THREADS[filepath] = threading.current_thread() + except FileExistsError: + try: + while THREADS[filepath].is_alive(): + monkeypatch.sleep(0.2) + return filepath + except KeyError: + return filepath + try: + viddata = urllib.request.urlopen(url).read() + except Exception as E: + print("Got "+str(E)+" while downloading file: "+url) + return filepath + f.write(viddata) + f.close() + return filepath + +def getAudio(url): + filename = urllib.parse.urlparse(url).netloc+os.path.basename(urllib.parse.urlparse(url).path) + filepath = CACHE+'/audio/'+filename + try: + f = open(filepath, mode='xb+') + THREADS[filepath] = threading.current_thread() + except FileExistsError: + try: + while THREADS[filepath].is_alive(): + monkeypatch.sleep(0.2) + return filepath + except KeyError: + return filepath + try: + viddata = urllib.request.urlopen(url).read() + except Exception as E: + print("Got "+str(E)+" while downloading file: "+url) + return filepath + f.write(viddata) + f.close() + return filepath + +def _mkdir(_dir): + if os.path.isdir(_dir): pass + elif os.path.isfile(_dir): + raise OSError("%s exists as a regular file." % _dir) + else: + parent, directory = os.path.split(_dir) + if parent and not os.path.isdir(parent): _mkdir(parent) + if directory: os.mkdir(_dir) + +def getAvi(instance): + filename = instance+'avi.png' + filepath = APPDATA+'/'+filename + try: + f = open(filepath, mode='xb+') + except FileExistsError: + return + try: + imgdata = urllib.request.urlopen('https://'+instance+'/images/avi.png').read() + except Exception as E: + print("Couldn't get default avi for "+instance+", error: "+str(E)) + return + f.write(imgdata) + f.close() + return filepath + +def updateAnimationPref(): + if ex.animatePicturesAction.isChecked(): + STATIC_PREF = '' + else: + STATIC_PREF = 'static_' + +if __name__ == '__main__': + _mkdir(CACHE+'/img/') + _mkdir(CACHE+'/vid/') + _mkdir(CACHE+'/audio/') + _mkdir(APPDATA) + app = QApplication(sys.argv) + app.setOrganizationName("plchat") + app.setApplicationName("plchat") + ex = App(app.processEvents) + sys.exit(ex._eventloop.run_forever()) \ No newline at end of file diff --git a/pleroma.py b/pleroma.py new file mode 100644 index 0000000..21f4c00 --- /dev/null +++ b/pleroma.py @@ -0,0 +1,200 @@ +import re, requests, os.path, websockets, json + +class Account(): + def __init__(self, instance, username=' ', password='', clientID=None, clientSecret=None, token=None, refresh_token=None): + scheme = re.compile('https?://') + self.instance = re.sub(scheme, '', instance) + if(self.instance[len(self.instance) - 1] == '/'): + self.instance = self.instance[0:len(self.instance) - 1] + self.url = 'https://'+self.instance + if(username[0] == '@'): + self.username = username[1:] + else: + self.username = username + + self.password = password + + self.clientID = clientID + self.clientSecret = clientSecret + self.token = '' + self.refresh_token = refresh_token + if token: + self.token = token + self._chatFlag = False + self._instanceInfo = None + self.flakeid = None + self.acct = None + + self.chat_update = None + + # start streaming chat_updates + async def startStreaming(self, chat_update=None): + if not self.token: + return False + if chat_update: + self.chat_update = chat_update + uri = "wss://"+self.instance+'/api/v1/streaming?access_token='+self.token+'&stream=user:pleroma_chat' + async with websockets.connect(uri) as websocket: + while True: + res = json.loads(await websocket.recv()) + if self.chat_update: + self.chat_update(self, res) + + # set streaming event handler + def setChatUpdate(self, chat_update): + self.chat_update = chat_update + + # register app if we don't have one + def register(self): + if not self.clientID or not self.clientSecret: + request_data = { + 'client_name': "plchat", + 'scopes': "read write follow push", + 'redirect_uris': "urn:ietf:wg:oauth:2.0:oob" + } + response = self.apiRequest('POST', '/api/v1/apps', request_data) + self.clientID, self.clientSecret = (response['client_id'], response['client_secret']) + return self.clientID, self.clientSecret + + # get oauth token + def getToken(self): + request_data = { + 'grant_type': 'password', + 'username': self.username, + 'password': self.password, + 'client_id': self.clientID, + 'client_secret': self.clientSecret, + 'scopes': "read write follow push", + 'redirect_uris': "urn:ietf:wg:oauth:2.0:oob" + } + response = self.apiRequest('POST', '/oauth/token', request_data) + self.token = response['access_token'] + self.refresh_token = response['refresh_token'] + r = self.apiRequest('GET', '/api/v1/accounts/verify_credentials') + self.flakeid = r['id'] + self.acct = r + return response + + # Ensure we have have some credentials + def login(self): + r = self.apiRequest('GET', '/api/v1/accounts/verify_credentials') + if 'id' in r: + self.flakeid = r['id'] + self.acct = r + if 'error' in r: + if not self.refresh_token: + return self.getToken() + else: + r = self.apiRequest('POST', '/oauth/token', { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + 'client_id': self.clientID, + 'client_secret': self.clientSecret, + 'scopes': "read write follow push", + 'redirect_uris': "urn:ietf:wg:oauth:2.0:oob" + }) + self.token = r['access_token'] + self.refresh_token = r['refresh_token'] + r = self.apiRequest('GET', '/api/v1/accounts/verify_credentials') + self.acct = r + self.flakeid = r['id'] + return r + return {'access_token': self.token, 'refresh_token': self.refresh_token} + + #pass True to always get new info + def getInstanceInfo(self, f=False): + if f or not self._instanceInfo: + r = self.apiRequest('GET', '/api/v1/instance') + self._instanceInfo = r + return r + else: + return self._instanceInfo + + # enter an account name (eg lain@lain.com) get back a flakeid + # get back None if we couldn't find it + def getAcctID(self, account): + response = self.apiRequest('GET', '/api/v1/accounts/search', {'q': account, 'limit':1, 'resolve': True}) + for acct in response: + if acct['acct'] == account: + return acct['id'] + return None + + # enter an account name (eg lain@lain.com) get back all account info + # get back None if we couldn't find it + def getAcctInfo(self, account): + response = self.apiRequest('GET', '/api/v1/accounts/search', {'q': account, 'limit':1, 'resolve': True}) + for acct in response: + if acct['acct'] == account: + return acct + return None + + # enter a flakeid, get back a chat. will be created if not existing + def addChat(self, flakeid): + r = self.apiRequest('POST', '/api/v1/pleroma/chats/by-account-id/'+flakeid) + return r + + # get a chat + # this is the *chat* id, not the flakeid + def getChat(self, cid): + return self.apiRequest('GET', '/api/v1/pleroma/chats/'+cid) + + # optional page parameter, index of 0 + # limit is number of items per page + # if the server is too old to support pagination you will get back a list of all chats + def listChats(self, page=0, limit=20, with_muted=True): + page *= limit + if self._chatFlag: + r = self.apiRequest('GET', '/api/v1/pleroma/chats') + return r + r = self.apiRequest('GET', '/api/v2/pleroma/chats', {'with_muted': with_muted, 'offset': page, 'limit': limit}) + if 'error' in r and r['error'] == 'Not implemented': + self._chatFlag = True + return self.listChats(page, limit) + return r + + # list pages in chat, + # optional page parameter, index of 0 + # limit is number of items per page + def getMessages(self, cid, page=0, limit=20, past=None): + page *= limit + if past: + return self.apiRequest('GET', '/api/v1/pleroma/chats/'+cid+'/messages', {'offset': page, 'limit': limit, 'max_id': past}) + return self.apiRequest('GET', '/api/v1/pleroma/chats/'+cid+'/messages', {'offset': page, 'limit': limit}) + + # send a message to specified chat + # either conent or media id must be specified + def sendMessage(self, cid, content=None, media=None): + if not content and not media: + return 'You fucked up.' + r = self.apiRequest('POST', '/api/v1/pleroma/chats/'+cid+'/messages', {'content': content, 'media_id': media}) + return r + + # upload a piece of media, get back an object describing it + # the id attribute can be used with sendMessage() + def uploadMedia(self, file): + with open(file, 'rb') as f: + r = self.apiRequest('POST', '/api/v1/media', None, files={'file': f}) + return r + + # mark all messages in a chat (up to last_read_id) as read + def markChatRead(self, cid, last_read_id): + return self.apiRequest('POST', '/api/v1/pleroma/chats/'+cid+'/read', {'last_read_id': last_read_id}) + + # enter a chat it and a message id, get back the deleted message on success + def deleteMessage(self, cid, mid): + return self.apiRequest('DELETE', '/api/v1/pleroma/chats/'+cid+'/messages/'+mid) + + # internal function + def apiRequest(self, method, route, data=None, **kwargs): + if method == 'GET': + response = requests.get(self.url + route, data, timeout=300, headers={'Authorization': 'Bearer '+self.token}, **kwargs) + response = response.json() + return response + if method == 'POST': + response = requests.post(self.url + route, data, timeout=300, headers={'Authorization': 'Bearer '+self.token}, **kwargs) + response = response.json() + return response + if method == 'DELETE': + response = requests.delete(self.url + route, data, timeout=300, headers={'Authorization': 'Bearer '+self.token}, **kwargs) + response = response.json() + return response \ No newline at end of file diff --git a/send.svg b/send.svg new file mode 100644 index 0000000..4dd005f --- /dev/null +++ b/send.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + + + + + + + + + diff --git a/timeconvert.py b/timeconvert.py new file mode 100644 index 0000000..7598fb2 --- /dev/null +++ b/timeconvert.py @@ -0,0 +1,15 @@ +from dateutil.parser import * +from dateutil.tz import * +from datetime import * + +def utc_to_local(utc, twofourhr=False): + if twofourhr: + clockfmt = '%H:%M' + else: + clockfmt = '%I:%M%p' + time = parse(utc) + local = time.astimezone(tzlocal()) + today = datetime.today() + if local.day == today.day and local.month == today.month and local.year == today.year: + return datetime.strftime(local, '%I:%M%p') + return datetime.strftime(local, '%I:%M%p %m-%d-%y') \ No newline at end of file diff --git a/unread.svg b/unread.svg new file mode 100644 index 0000000..fc89420 --- /dev/null +++ b/unread.svg @@ -0,0 +1,180 @@ + + + + Icon Letter Mail + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + icon_letter_mail + 2010-01-29T13:59:32 + + https://openclipart.org/detail/29117/icon_letter_mail-by-jean_victor_balin + + + jean_victor_balin + + + + + icon + letter + mail + mailing + unchecked + + + + + + + + + + + diff --git a/videowidget.py b/videowidget.py new file mode 100644 index 0000000..f0bc32a --- /dev/null +++ b/videowidget.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python + + +############################################################################# +## +## Copyright (C) 2013 Riverbank Computing Limited. +## Copyright (C) 2013 Digia Plc and/or its subsidiary(-ies). +## All rights reserved. +## +## This file is part of the examples of PyQt. +## +## $QT_BEGIN_LICENSE:BSD$ +## You may use this file under the terms of the BSD license as follows: +## +## "Redistribution and use in source and binary forms, with or without +## modification, are permitted provided that the following conditions are +## met: +## * Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## * Redistributions in binary form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. +## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor +## the names of its contributors may be used to endorse or promote +## products derived from this software without specific prior written +## permission. +## +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." +## $QT_END_LICENSE$ +## +############################################################################# + +from PyQt5 import QtWidgets, QtCore +from PyQt5.QtCore import QDir, Qt, QUrl, QSize +from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer, QMediaPlaylist +from PyQt5.QtMultimediaWidgets import QVideoWidget +from PyQt5.QtWidgets import (QApplication, QFileDialog, QHBoxLayout, QLabel, + QPushButton, QSizePolicy, QSlider, QStyle, QVBoxLayout, QWidget, QDesktopWidget) +from PyQt5.QtGui import QDesktopServices + + +class VideoPlayer(QWidget): + + def __init__(self, url, parent=None): + super(VideoPlayer, self).__init__(parent) + + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) + + self.mediaPlayer = QMediaPlayer(None, QMediaPlayer.VideoSurface) + + self.videoWidget = LinkPlayer(url) + self.videoWidget.setAspectRatioMode(Qt.KeepAspectRatio) + + self.playlist = QMediaPlaylist() + self.playlist.setPlaybackMode(QMediaPlaylist.Loop) + + self.mediaPlayer.setPlaylist(self.playlist) + + self.playButton = QPushButton() + self.playButton.setEnabled(False) + self.playButton.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) + self.playButton.clicked.connect(self.play) + + self.positionSlider = ClickSlider() + self.positionSlider.setOrientation(Qt.Horizontal) + self.positionSlider.setRange(0, 0) + self.positionSlider.sliderMoved.connect(self.setPosition) + + #self.volumeSlider = QSlider(Qt.Horizontal) + #self.volumeSlider.setRange(0, 100) + #self.volumeSlider.setSliderPosition(100) + #self.volumeSlider.sliderMoved.connect(self.mediaPlayer.setVolume) + + self.volumeButton = QPushButton() + self.volumeButton.setEnabled(False) + self.volumeButton.setIcon(self.style().standardIcon(QStyle.SP_MediaVolume)) + self.volumeButton.clicked.connect(self.doMute) + + self.errorLabel = QLabel() + self.errorLabel.setSizePolicy(QSizePolicy.Preferred, + QSizePolicy.Maximum) + + controlLayout = QHBoxLayout() + controlLayout.setContentsMargins(0, 0, 0, 0) + controlLayout.addWidget(self.playButton, 1) + controlLayout.addWidget(self.positionSlider, 10) + controlLayout.addWidget(self.volumeButton, 1) + #controlLayout.addWidget(self.volumeSlider, 1) + + layout = QVBoxLayout() + layout.addWidget(self.videoWidget) + layout.addLayout(controlLayout) + layout.addWidget(self.errorLabel) + + self.setLayout(layout) + + self.mediaPlayer.setVideoOutput(self.videoWidget) + self.mediaPlayer.stateChanged.connect(self.mediaStateChanged) + self.mediaPlayer.positionChanged.connect(self.positionChanged) + self.mediaPlayer.durationChanged.connect(self.durationChanged) + self.mediaPlayer.error.connect(self.handleError) + + def openFile(self, fileName): + if fileName != '': + + self.playlist.addMedia(QMediaContent(QUrl(fileName))) + self.playlist.next() + + + self.mediaPlayer.play() + self.mediaPlayer.pause() + #self.mediaPlayer.setMedia( + # QMediaContent(QUrl(fileName))) + #QMediaContent(QUrl.fromLocalFile(fileName))) + self.playButton.setEnabled(True) + self.volumeButton.setEnabled(True) + #self.mediaPlayer.playlist().setPlaybackMode(QMediaPlaylist.Loop) + + def play(self): + if self.mediaPlayer.state() == QMediaPlayer.PlayingState: + self.mediaPlayer.pause() + else: + self.mediaPlayer.play() + + def mediaStateChanged(self, state): + if self.mediaPlayer.state() == QMediaPlayer.PlayingState: + self.playButton.setIcon( + self.style().standardIcon(QStyle.SP_MediaPause)) + else: + self.playButton.setIcon( + self.style().standardIcon(QStyle.SP_MediaPlay)) + + def positionChanged(self, position): + self.positionSlider.setValue(position) + + def durationChanged(self, duration): + self.positionSlider.setRange(0, duration) + + def setPosition(self, position): + self.mediaPlayer.setPosition(position) + + def doMute(self, checked): + self.mediaPlayer.setMuted(not self.mediaPlayer.isMuted()) + if self.mediaPlayer.isMuted(): + self.volumeButton.setIcon(self.style().standardIcon(QStyle.SP_MediaVolumeMuted)) + else: + self.volumeButton.setIcon(self.style().standardIcon(QStyle.SP_MediaVolume)) + + def handleError(self): + self.playButton.setEnabled(False) + self.errorLabel.setText("Error: " + self.mediaPlayer.errorString()) + + def sizeHint(self): + return QSize(250, round(QDesktopWidget().screenGeometry(-1).height() * 0.6)) + +class ClickSlider(QtWidgets.QSlider): + def mousePressEvent(self, event): + super(ClickSlider, self).mousePressEvent(event) + if event.button() == QtCore.Qt.LeftButton: + val = self.pixelPosToRangeValue(event.pos()) + self.setValue(val) + + def pixelPosToRangeValue(self, pos): + opt = QtWidgets.QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self) + sr = self.style().subControlRect(QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderHandle, self) + + if self.orientation() == QtCore.Qt.Horizontal: + sliderLength = sr.width() + sliderMin = gr.x() + sliderMax = gr.right() - sliderLength + 1 + else: + sliderLength = sr.height() + sliderMin = gr.y() + sliderMax = gr.bottom() - sliderLength + 1; + pr = pos - sr.center() + sr.topLeft() + p = pr.x() if self.orientation() == QtCore.Qt.Horizontal else pr.y() + return QtWidgets.QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), p - sliderMin, + sliderMax - sliderMin, opt.upsideDown) + +class LinkPlayer(QVideoWidget): + def __init__(self, url, *args, **kwargs): + super(LinkPlayer, self).__init__(*args, **kwargs) + self.url = QUrl(url) + super().setCursor(Qt.PointingHandCursor) + def mouseReleaseEvent(self, event): + bttn = event.button() + modif = event.modifiers() + if modif == Qt.NoModifier and bttn == Qt.LeftButton: + QDesktopServices.openUrl(self.url) + +if __name__ == '__main__': + + import sys + + app = QApplication(sys.argv) + + player = VideoPlayer() + player.resize(320, 240) + player.show() + + sys.exit(app.exec_())