diff --git a/.flake8 b/.flake8 index f2b41384..7678200d 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,7 @@ +[flake8] max-line-length=100 -application_import_names=projectt -ignore=P102,B311,W503,E226,S311,W504,F821 -exclude=__pycache__, venv, .venv, tests +application_import_names=project +ignore=W503,B305 +exclude=__pycache__, venv, .venv, tests, project/ui import-order-style=pycharm +inline-quotes = " diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 00000000..24c159c2 --- /dev/null +++ b/.gitconfig @@ -0,0 +1,4 @@ +[core] + autocrlf = input + eol = lf + whitespace=trailing-space,space-before-tab,-indent-with-non-tab,tab-in-indent,-cr-at-eol,tabwidth=4 diff --git a/.gitignore b/.gitignore index 894a44cc..7f5388be 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,56 @@ venv.bak/ # mypy .mypy_cache/ + +# C++ objects and libs +*.slo +*.lo +*.o +*.a +*.la +*.lai +*.so +*.dll +*.dylib + +# Qt-es +object_script.*.Release +object_script.*.Debug +*_plugin_import.cpp +/.qmake.cache +/.qmake.stash +*.pro.user +*.pro.user.* +*.qbs.user +*.qbs.user.* +*.moc +moc_*.cpp +moc_*.h +qrc_*.cpp +ui_*.h +*.qmlc +*.jsc +Makefile* +*build-* + +# Qt unit tests +target_wrapper.* + +# QtCreator +*.autosave + +# QtCreator Qml +*.qmlproject.user +*.qmlproject.user.* + +# QtCreator CMake +CMakeLists.txt.user* + +# QtCreator 4.8< compilation database +compile_commands.json + +# QtCreator local machine specific files for imported projects +*creator.user* + +qt/build/ +library.sqlite diff --git a/LICENSE b/LICENSE index 42b7b6f8..2fa20d5b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2019 Python Discord +Original Copyright (c) 2019 Python Discord +Copyright (c) 2019 Slithering Snacks Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Pipfile b/Pipfile index 72b70b6f..996f1855 100644 --- a/Pipfile +++ b/Pipfile @@ -4,12 +4,19 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] -flake8 = "*" +flake8-bugbear = "*" +flake8-import-order = "*" +flake8-quotes = "*" +flake8-tidy-imports = "*" [packages] +pyside2 = "*" +qdarkstyle = "*" +captcha = "*" [requires] python_version = "3.7" [scripts] -lint = "python -m flake8" \ No newline at end of file +lint = "python -m flake8" +start = "python -m project" diff --git a/Pipfile.lock b/Pipfile.lock index 79354a3c..1b795f51 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a376db0bd471e38a7080cd854c46349b46922db98afeaf83d17b84923fbe9710" + "sha256": "69321450ec40a8eacaa6177aaf125fa3557a2c35425499ac4086a9820642be65" }, "pipfile-spec": 6, "requires": { @@ -15,8 +15,96 @@ } ] }, - "default": {}, + "default": { + "captcha": { + "hashes": [ + "sha256:1671f194da3b535fc12f6b0eb349195c7b28a6641381b2c07e31d04aa92fb6fc", + "sha256:a6b28a120de0a37c44415e70225978e36b2645940133f2474c7a109b2d4683e2" + ], + "index": "pypi", + "version": "==0.3" + }, + "pillow": { + "hashes": [ + "sha256:051de330a06c99d6f84bcf582960487835bcae3fc99365185dc2d4f65a390c0e", + "sha256:0ae5289948c5e0a16574750021bd8be921c27d4e3527800dc9c2c1d2abc81bf7", + "sha256:0b1efce03619cdbf8bcc61cfae81fcda59249a469f31c6735ea59badd4a6f58a", + "sha256:163136e09bd1d6c6c6026b0a662976e86c58b932b964f255ff384ecc8c3cefa3", + "sha256:18e912a6ccddf28defa196bd2021fe33600cbe5da1aa2f2e2c6df15f720b73d1", + "sha256:24ec3dea52339a610d34401d2d53d0fb3c7fd08e34b20c95d2ad3973193591f1", + "sha256:267f8e4c0a1d7e36e97c6a604f5b03ef58e2b81c1becb4fccecddcb37e063cc7", + "sha256:3273a28734175feebbe4d0a4cde04d4ed20f620b9b506d26f44379d3c72304e1", + "sha256:4c678e23006798fc8b6f4cef2eaad267d53ff4c1779bd1af8725cc11b72a63f3", + "sha256:4d4bc2e6bb6861103ea4655d6b6f67af8e5336e7216e20fff3e18ffa95d7a055", + "sha256:505738076350a337c1740a31646e1de09a164c62c07db3b996abdc0f9d2e50cf", + "sha256:5233664eadfa342c639b9b9977190d64ad7aca4edc51a966394d7e08e7f38a9f", + "sha256:5d95cb9f6cced2628f3e4de7e795e98b2659dfcc7176ab4a01a8b48c2c2f488f", + "sha256:7eda4c737637af74bac4b23aa82ea6fbb19002552be85f0b89bc27e3a762d239", + "sha256:801ddaa69659b36abf4694fed5aa9f61d1ecf2daaa6c92541bbbbb775d97b9fe", + "sha256:825aa6d222ce2c2b90d34a0ea31914e141a85edefc07e17342f1d2fdf121c07c", + "sha256:9c215442ff8249d41ff58700e91ef61d74f47dfd431a50253e1a1ca9436b0697", + "sha256:a3d90022f2202bbb14da991f26ca7a30b7e4c62bf0f8bf9825603b22d7e87494", + "sha256:a631fd36a9823638fe700d9225f9698fb59d049c942d322d4c09544dc2115356", + "sha256:a6523a23a205be0fe664b6b8747a5c86d55da960d9586db039eec9f5c269c0e6", + "sha256:a756ecf9f4b9b3ed49a680a649af45a8767ad038de39e6c030919c2f443eb000", + "sha256:b117287a5bdc81f1bac891187275ec7e829e961b8032c9e5ff38b70fd036c78f", + "sha256:ba04f57d1715ca5ff74bb7f8a818bf929a204b3b3c2c2826d1e1cc3b1c13398c", + "sha256:cd878195166723f30865e05d87cbaf9421614501a4bd48792c5ed28f90fd36ca", + "sha256:cee815cc62d136e96cf76771b9d3eb58e0777ec18ea50de5cfcede8a7c429aa8", + "sha256:d1722b7aa4b40cf93ac3c80d3edd48bf93b9208241d166a14ad8e7a20ee1d4f3", + "sha256:d7c1c06246b05529f9984435fc4fa5a545ea26606e7f450bdbe00c153f5aeaad", + "sha256:e9c8066249c040efdda84793a2a669076f92a301ceabe69202446abb4c5c5ef9", + "sha256:f227d7e574d050ff3996049e086e1f18c7bd2d067ef24131e50a1d3fe5831fbc", + "sha256:fc9a12aad714af36cf3ad0275a96a733526571e52710319855628f476dcb144e" + ], + "version": "==5.4.1" + }, + "pyside2": { + "hashes": [ + "sha256:006ac882190cb2cb8b6d1db521196710e18e4fbfffcb54e7fe4e7878b89e5644", + "sha256:171d0e43bb241a12c98ab7ae46706af8e7929c973225d1c9abaf18a73dc3dbf0", + "sha256:31ca082427d2104b9ab61520c0a1435ce23073e54734f3bd671b37be471da9c1", + "sha256:379bb7125c5b6fd13f2992ba52f44015f8936e52ce38288c599bfcb8d66a963e", + "sha256:4d948c441d609868b77bdd8cdab7ac6e595a6f15ca82dde3fd700f73445da821", + "sha256:4f7518cfa049272ce64e43ce06cf151bdd648c6884272d480ee4b188dbffc7f2", + "sha256:76ce97419813fcd949c841542817ba2f1e69f115b682ee9e3a07a82b784f8130", + "sha256:99eb0afcaed70bf55582ae93b663e1c32aa76e5eeae98a3bba36b6a16319bf81", + "sha256:9d17a32dab00851743363fc6bde1cbb4f5f979f321a4467321c70d836ae423f3", + "sha256:d5e39cc5868e0b1415846df185a22940219bf1a5ec38a7d9be6c0cd15f413336", + "sha256:dba738ae3cbe7f90360e0729832d741c6f74698da44614b18d44393f72745022", + "sha256:f1b054ae5870cb3a9aec08d8eb0ff193d2a29dde0f40b947a9807bd64db647a7" + ], + "index": "pypi", + "version": "==5.12.1" + }, + "qdarkstyle": { + "hashes": [ + "sha256:0b30e55672d51abdf74710991d6abfd22584a13c1298a09108c6056411111a8f", + "sha256:96b14cd0440a0f73db4e14c5accdaa08072625d0395ae011d444508cbd73eb9e" + ], + "index": "pypi", + "version": "==2.6.5" + }, + "shiboken2": { + "hashes": [ + "sha256:034ea9104506dcf1c77308a73a7840f1715054b3aaeb86c7ae8b260e6572e410", + "sha256:479a44f3b11c5d0ba3d581470db28b0788214526b2a26e173890aef47328d945", + "sha256:49cb68fddd8bd302c0a9eb09554a99cad1b63bfc9440dd016b4fcd300af5612a", + "sha256:c2a2261c906065b4fb1725bb075a841c72e44e28ca642adb06663d8299d0d9fc", + "sha256:cf10631e1970ce22b3bc40673288afd55ba9b8a0d803ab0e4ed525b4a68ef6b0", + "sha256:f4038e97eeffea96bcae932f005a3f7772734b38928e0d44e48af654525c38e3" + ], + "version": "==5.12.1" + } + }, "develop": { + "attrs": { + "hashes": [ + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + ], + "version": "==19.1.0" + }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -26,11 +114,41 @@ }, "flake8": { "hashes": [ - "sha256:6d8c66a65635d46d54de59b027a1dda40abbe2275b3164b634835ac9c13fd048", - "sha256:6eab21c6e34df2c05416faa40d0c59963008fff29b6f0ccfe8fa28152ab3e383" + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + ], + "version": "==3.7.7" + }, + "flake8-bugbear": { + "hashes": [ + "sha256:07b6e769d7f4e168d590f7088eae40f6ddd9fa4952bed31602def65842682c83", + "sha256:0ccf56975f4db1d69dc1cf3598c99d768ebf95d0cad27d76087954aa399b515a" + ], + "index": "pypi", + "version": "==18.8.0" + }, + "flake8-import-order": { + "hashes": [ + "sha256:9be5ca10d791d458eaa833dd6890ab2db37be80384707b0f76286ddd13c16cbf", + "sha256:feca2fd0a17611b33b7fa84449939196c2c82764e262486d5c3e143ed77d387b" + ], + "index": "pypi", + "version": "==0.18" + }, + "flake8-quotes": { + "hashes": [ + "sha256:fd9127ad8bbcf3b546fa7871a5266fd8623ce765ebe3d5aa5eabb80c01212b26" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "flake8-tidy-imports": { + "hashes": [ + "sha256:1c476aabc6e8db26dc75278464a3a392dba0ea80562777c5f13fd5cdf2646154", + "sha256:b3f5b96affd0f57cacb6621ed28286ce67edaca807757b51227043ebf7b136a1" ], "index": "pypi", - "version": "==3.7.6" + "version": "==2.0.0" }, "mccabe": { "hashes": [ @@ -48,10 +166,10 @@ }, "pyflakes": { "hashes": [ - "sha256:5e8c00e30c464c99e0b501dc160b13a14af7f27d4dffb529c556e30a159e231d", - "sha256:f277f9ca3e55de669fba45b7393a1449009cff5a37d1af10ebb76c52765269cd" + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" ], - "version": "==2.1.0" + "version": "==2.1.1" } } } diff --git a/README.md b/README.md index 697c2bf7..e0b68634 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ The theme for this code jam will be **This app hates you!**. You will be creating an application using a GUI library of your choice in Python. The application must serve a real purpose, but must also fit the theme. -You can use any GUI library that you wish to use, but you have to make _a desktop app_. For example, you may use frameworks like PySide, PyQt, tkinter, or wxPython. You can even use stuff like Kivy or PyGame, although we do not recommend that you do. You may not, however, use webframeworks like Django or Flask, and you may not use anything that turns HTML and CSS into a desktop app that runs as a browser. +You can use any GUI library that you wish to use, but you have to make _a desktop app_. For example, you may use frameworks like PySide, PyQt, tkinter, or wxPython. You can even use stuff like Kivy or PyGame, although we do not recommend that you do. You may not, however, use webframeworks like Django or Flask, and you may not use anything that turns HTML and CSS into a desktop app that runs as a browser. Here are a couple of examples of what we mean by an application that "serves a real purpose but also fits the theme": * A calculator app that calculates the right answers, but represents the answer in a way that's completely impractical. * An image resizer where you have to specify which part of the image to resize, specify how much force to apply to the resize operation in newtons, and then manually resize the image by turning a crank. * An alarm clock app that plays a very loud sound effect every 5 minutes reminding you that your alarm will ring in 6 hours. The closer it gets to the 6 hour mark, the lower the volume of the sound effect. When the time is up, the sound effect is virtually inaudible. -Remember that teamwork is not optional for our code jams - You must find a way to work together. For this jam, we've assigned a leader for each team based on their responses to the application form. Remember to listen to your leader, and communicate with the rest of your team! +Remember that teamwork is not optional for our code jams - You must find a way to work together. For this jam, we've assigned a leader for each team based on their responses to the application form. Remember to listen to your leader, and communicate with the rest of your team! **Remember to provide instructions on how to set up and run your app at the bottom of this README**. @@ -22,7 +22,7 @@ Remember that teamwork is not optional for our code jams - You must find a way t # Setting Up -You should be using [Pipenv](https://pipenv.readthedocs.io/en/latest/). Take a look +You should be using [Pipenv](https://pipenv.readthedocs.io/en/latest/). Take a look [at the documentation](https://pipenv.readthedocs.io/en/latest/) if you've never used it before. In short: * Setting up for development: `pipenv install --dev` @@ -30,16 +30,52 @@ You should be using [Pipenv](https://pipenv.readthedocs.io/en/latest/). Take a l # Project Information -`# TODO` +Slithering Snacks's submission for the 4th code jam. + +Qt, via PySide2 ("Qt for Python"), is used as the GUI framework. The Qt Multimedia module is an easy, cross-platform solution to playing media. The UI windows are designed in Qt Creator and then converted to Python using `pyside2-uic`. + +SQLite is used to drive a database which persists the media playlist. ## Description -`# TODO` +A basic media player with the following features: + + * Previous and next navigation of playlist + * Seeking + * Persistent and sortable playlist + * Parsing and displaying metadata tags of media ## Setup & Installation -`# TODO` +First, make sure [ffmpeg](https://ffmpeg.org/) is installed. Particularily, this program relies on `ffprobe`. It should either be on your `PATH` or in the current working directory when running the program. + +Open a terminal in the repository's root directory and execute the following command to install the project's dependencies: + +```bash +pipenv --sync +``` + +To run the program, execute + +```bash +pipenv run start +``` ## How do I use this thing? -`# TODO` +#### Adding media +Click on `File` in the menu bar, then click on `Add files`. In the file dialogue which opens, browse for and select the media to add to the playlist. + +#### Removing media +Right click on a row in the playlist, select the `Remove` action on the context menu, and confirm removal in the prompts which appear. + +#### Playing media +Click on the `Play` button to start playing media. To pause, click on the same button again. Notice that when media is playing, the button changes from `Play` to `Pause`. The `Previous` and `Next` may be used to navigate the playlist. + +The time remaining on the current song can be seen below the playlist, to the left of the slider. It display the remaining time in hexadecimal. + +#### Seeking +Click on the slider below the playlist to seek. In the dialogue that opens, use the dials to select the hour, minute, and second you wish to seek. Note that the values below the dials are _octal_ numbers. + +#### Playlist +Media can be sorted on the playlist by clicking on the column headers. An indicator appears on the header by which the playlist is being sorted. diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 00000000..e162aec2 --- /dev/null +++ b/project/__init__.py @@ -0,0 +1,11 @@ +import logging +import os +import sys + +DEBUG = os.environ.get("DEBUG", False) + +logging.basicConfig( + format="%(asctime)s | %(name)-32s | %(levelname)-8s | %(message)s", + level=logging.DEBUG if DEBUG else logging.INFO, + handlers=(logging.StreamHandler(stream=sys.stdout),) +) diff --git a/project/__main__.py b/project/__main__.py index e69de29b..fbb8a851 100644 --- a/project/__main__.py +++ b/project/__main__.py @@ -0,0 +1,3 @@ +from project import music_player + +music_player.main() diff --git a/project/delegates/__init__.py b/project/delegates/__init__.py new file mode 100644 index 00000000..6398caf5 --- /dev/null +++ b/project/delegates/__init__.py @@ -0,0 +1,3 @@ +from project.delegates.current_media import CurrentMediaDelegate + +__all__ = ("CurrentMediaDelegate",) diff --git a/project/delegates/current_media.py b/project/delegates/current_media.py new file mode 100644 index 00000000..04747bcc --- /dev/null +++ b/project/delegates/current_media.py @@ -0,0 +1,9 @@ +from PySide2.QtCore import QModelIndex +from PySide2.QtGui import QPainter +from PySide2.QtWidgets import QStyleOptionViewItem, QStyledItemDelegate + + +class CurrentMediaDelegate(QStyledItemDelegate): + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + option.font.setBold(True) + super().paint(painter, option, index) diff --git a/project/media.py b/project/media.py new file mode 100644 index 00000000..9d08183f --- /dev/null +++ b/project/media.py @@ -0,0 +1,224 @@ +import binascii +import json +import logging +import subprocess +from pathlib import Path +from typing import Any, Dict, Iterable + +from PySide2.QtCore import QUrl, Signal +from PySide2.QtMultimedia import QMediaContent, QMediaPlayer +from PySide2.QtSql import QSqlRecord, QSqlTableModel + +from project.playlist import Playlist + +log = logging.getLogger(__name__) + +TAG_WHITELIST = ("title", "artist", "album", "date", "genre") + + +def _compute_crc32(path: str) -> int: + """Compute and return the CRC-32 of a file at `path`. + + Parameters + ---------- + path: str + The path to the file. + + Returns + ------- + int + The CRC-32 of the data in the file. + + """ + with open(path, "rb") as file: + data = file.read() + return binascii.crc32(data) + + +def _parse_media(path: str) -> Dict[str, Any]: + """Parse the metadata of a media file and return it as a dictionary. + + Parameters + ---------- + path: str + The path to the media file. + + Returns + ------- + Dict[str, Any] + The media's metadata, or None on failure. + + """ + args = [ + "ffprobe", + "-hide_banner", + "-loglevel", "error", + "-of", "json", + "-show_entries", "format_tags", + path + ] + + process = subprocess.run(args, capture_output=True, encoding="utf-8") + tags = dict() + + if process.returncode != 0: + log.error(f"Failed to fetch metadata for {path}: return code {process.returncode}") + log.debug(process.stderr) + else: + try: + metadata = json.loads(process.stdout, encoding="utf-8") + tags = metadata["format"]["tags"] + except (json.JSONDecodeError, KeyError): + log.exception("Failed to parse metadata for {path}") + + # Filter out unsupported tags and make them all lowercase. + tags = {k.lower(): v for k, v in tags.items() if k.lower() in TAG_WHITELIST} + + tags["path"] = path + tags["crc32"] = _compute_crc32(path) + + # Use the file name as the title if one doesn't exist. + if not tags.get("title"): + tags["title"] = Path(path).stem + + return tags + + +class Player(QMediaPlayer): + media_added = Signal() + media_removed = Signal() + + def __init__(self, model: QSqlTableModel, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._model = model + + self._playlist = Playlist(self._model) + self._playlist.setPlaybackMode(Playlist.Loop) + self._playlist.currentIndexChanged.connect(self.playlist_index_changed) + + self.error.connect(self.handle_error) + self.mediaStatusChanged.connect(self.media_status_changed) + self.stateChanged.connect(self.state_changed) + self.setPlaylist(self._playlist) + + def _create_record(self, metadata: Dict[str, Any]) -> QSqlRecord: + """Create and return a library record from media `metadata`. + + Parameters + ---------- + metadata: Dict[str, Any] + The media's metadata. + + Returns + ------- + QSqlRecord + The created record. + + """ + record = self._model.record() + record.remove(record.indexOf("id")) # id field is auto-incremented so it can be removed. + + for k, v in metadata.items(): + record.setValue(k, v) + + return record + + def add_media(self, paths: Iterable[str]): + """Add media from `paths` to the playlist. + + Parameters + ---------- + paths: Iterable[str] + The paths to the media files to add. + + """ + if not paths: + return + + # TODO: Let's just hope the commits and rollbacks always succeed for now... + self._model.database().transaction() + paths_added = [] + + for path in paths: + log.debug(f"Adding media for {path}") + + metadata = _parse_media(path) + record = self._create_record(metadata) + + if not self._model.insertRecord(-1, record): + log.error(f"Failed to add media for {path}: {self._model.lastError()}") + # Assuming the model wasn't ever modified if this failed; no revert needed. + else: + paths_added.append(path) + + if not self._model.submitAll(): + log.error(f"Failed to add media: could not submit changes.") + self._model.revertAll() + self._model.database().rollback() + + return + + self._model.database().commit() + + # It's safer to get the last inserted ID right after committing as opposed to getting it + # before inserting anything. + last_id = self._model.query().lastInsertId() + + # Populate the playlist. + for media_id, path in enumerate(paths_added, last_id - len(paths_added) + 1): + media = QMediaContent(QUrl.fromLocalFile(path)) + self.playlist().addMedia(media, media_id) + + self.media_added.emit() + + def remove_media(self, row: int) -> bool: + # TODO: Let's just hope the commits and rollbacks always succeed for now... + self._model.database().transaction() + + if not self._model.removeRow(row): + log.error(f"Failed to remove media at row {row} from the db: {self._model.lastError()}") + self._model.revertAll() + self._model.database().rollback() + return False + + self.playlist().removeMedia(row) + + if self._model.submitAll(): + self._model.database().commit() + self.media_removed.emit() + return True + else: + log.error(f"Failed to remove media at row {row}: could not submit changes.") + self._model.revertAll() + self._model.database().rollback() + + # Re-add the media. It should still be in the model if it was correctly reverted. + path = self._model.index(row, 7).data() + media_id = self.model.index(row, 0).data() + media = QMediaContent(QUrl.fromLocalFile(path)) + self.playlist().addMedia(media, media_id) + + return False + + def play(self): + # Workaround for current index not being set initially. + if self.playlist().currentIndex() == -1: + self.playlist().setCurrentIndex(0) + + super().play() + + @staticmethod + def state_changed(state): + log.debug(f"State changed: {state}") + + @staticmethod + def media_status_changed(status): + log.debug(f"Status changed: {status}") + + def playlist_index_changed(self, index: int): + name = self.playlist().currentMedia().canonicalUrl().fileName() + log.debug(f"Index changed: [{index:03d}] {name}") + + def handle_error(self, error): + log.error(f"{error}: {self.player.errorString()}") diff --git a/project/music_player.py b/project/music_player.py new file mode 100644 index 00000000..d8f53ee8 --- /dev/null +++ b/project/music_player.py @@ -0,0 +1,110 @@ +import sqlite3 +import sys + +import qdarkstyle +from PySide2.QtSql import QSqlDatabase, QSqlQuery +from PySide2.QtWidgets import QApplication, QDialog + +from project.widgets import CreatePassword, MainWindow, PasswordPrompt + +DB_NAME = "library.sqlite" + + +def create_db(): + """Create the playlist's database file and table if they don't exist. + + The created database should be accessed using :meth:`PySide2.QtSql.QSqlDatabase.database`, the + name being specified via :const:`project.library.DB_NAME`. + + """ + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute(""" + create table if not exists playlist ( + id integer primary key, + title text, + artist text, + album text, + genre text, + date text, + crc32 integer not null, + path text not null + ); + """) + cursor.execute(""" + create table if not exists credentials ( + id integer primary key, + password text + ); + """) # The lack of security is a feature ;) + conn.commit() + + db = QSqlDatabase.addDatabase("QSQLITE") + db.setDatabaseName(DB_NAME) + + # TODO: Handle possible errors if db fails to open + + +def create_password() -> str: + """Prompt for a password to be created and return it.""" + dialogue = CreatePassword() + dialogue.exec_() + + if dialogue.result() != QDialog.Accepted: + sys.exit() + + password = dialogue.new_password + + # upsert + query = QSqlQuery(QSqlDatabase.database()) + query.prepare(""" + insert into credentials (id, password) + values (0, :password) + on conflict (id) + do update set password=:password + """) + query.bindValue(":password", password) + query.exec_() + query.finish() + + return password + + +def get_password() -> str: + """Return the password from the database.""" + query = QSqlQuery(QSqlDatabase.database()) + query.exec_("select password from credentials") + + if query.next(): + password = query.value(0) + query.finish() + return password + + +def login() -> str: + """Prompt for the password and return it. Create one if one doesn't exist.""" + password = get_password() + if password: + prompt = PasswordPrompt(password) + prompt.exec_() + if prompt.result() != QDialog.Accepted: + sys.exit(1) + else: + password = create_password() + + return password + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("Music Player") + app.setStyleSheet(qdarkstyle.load_stylesheet_pyside2()) + + create_db() + password = login() + + window = MainWindow(password) + window.setWindowTitle("Music Player") + window.show() + + sys.exit(app.exec_()) diff --git a/project/playlist.py b/project/playlist.py new file mode 100644 index 00000000..45a086c4 --- /dev/null +++ b/project/playlist.py @@ -0,0 +1,231 @@ +import logging +from pathlib import Path +from typing import Dict + +from PySide2.QtCore import QAbstractItemModel, QUrl, Qt +from PySide2.QtMultimedia import QMediaContent, QMediaPlaylist + +log = logging.getLogger(__name__) + + +class Playlist(QMediaPlaylist): + """A wrapper for :class:`QMediaPlaylist` which navigates using a :class:`QAbstractItemModel`. + + Rather than using the :class:`QMediaPlaylist`'s regular internal data structure for determining + the previous or next media, the rows of a :class:`QAbstractItemModel` are used. This means that + :meth:`Playlist.next()` and :meth:`Playlist.previous()` will account for the order of the rows + in the model changing. + + This works by fetching the media id (the primary key) from the model for a given row. + + The playback mode :attr:`QMediaPlaylist.Random` is currently unsupported. + + """ + def __init__(self, model: QAbstractItemModel, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._model = model + self._media: Dict[int, QMediaContent] = {} + self._current_media = -1 + + self._populate() + + def _get_media_id(self, row: int) -> int: + """Return the media ID corresponding to the `row`.""" + return self._model.index(row, 0).data() + + def _get_row(self, media_id: int) -> int: + """Return the row index which corresponds to the `media_id`.""" + if media_id == -1: + return -1 + + start_index = self._model.index(0, 0) + matches = self._model.match(start_index, Qt.DisplayRole, media_id, flags=Qt.MatchExactly) + + return matches[0].row() # ids are unique so there should always only be one match anyway. + + def _jump(self, index: int): + """Set the current index and media to `index`.""" + log.debug(f"Jumping to index {index}") + if index < -1 or index >= self.mediaCount(): + index = -1 + + if index != -1: + self._current_media = self._get_media_id(index) + else: + self._current_media = -1 + + if index != self.currentIndex(): + self.currentIndexChanged.emit(index) + # self.surroundingItemsChanged.emit() + + # This should be equivalent to QMediaPlaylistNavigator's "activate" signal + self.currentMediaChanged.emit(self.currentMedia()) + + def _populate(self): + """Populate the playlist with existing media in the model.""" + for row in range(self._model.rowCount()): + path = self._model.index(row, 7).data() + if Path(path).is_file(): + media = QMediaContent(QUrl.fromLocalFile(path)) + media_id = self._get_media_id(row) + self.addMedia(media, media_id) + else: + # TODO: Prompt user to remove from model on failure + log.warning( + f"Could not populate playlist for row {row}: " + f"{path} does not exist or isn't a file." + ) + + def currentIndex(self) -> int: + return self._get_row(self._current_media) + + def setCurrentIndex(self, index: int): + log.debug(f"Setting index to {index}") + self._jump(index) + + def currentMedia(self) -> QMediaContent: + if self._current_media == -1: + return QMediaContent() + + return self._media[self._current_media] + + def addMedia(self, content: QMediaContent, media_id: int) -> bool: + """Append the media `content` to the playlist. + + Parameters + ---------- + content: QMediaContent + The media to append. + media_id: int + The ID of the media in the model. + + Returns + ------- + bool + Always True. + + """ + row = self._get_row(media_id) + + self.mediaAboutToBeInserted.emit(row, row) + self._media[media_id] = content + self.mediaInserted.emit(row, row) + + file_name = content.canonicalUrl().fileName() + log.debug(f"Added media with ID {media_id}: {file_name}") + + return True + + def insertMedia(self, *args, **kwargs): + raise NotImplementedError + + def moveMedia(self, *args, **kwargs): + raise NotImplementedError + + def removeMedia(self, index: int) -> bool: + """Remove the media at row `index` from the playlist. + + Parameters + ---------- + index: int + The row in the model which corresponds to the media. + + Returns + ------- + bool + Always True. + + """ + media_id = self._get_media_id(index) + + self.mediaAboutToBeRemoved.emit(index, index) + del self._media[media_id] + self.mediaRemoved.emit(index, index) + + log.debug(f"Removed media at row {index}, media_id {media_id}.") + + if index == self.currentIndex(): + # Effectively stops the playlist if the current media is removed. + self.setCurrentIndex(-1) + + return True + + def nextIndex(self, steps: int = 1) -> int: + if self.mediaCount() == 0: + return -1 + + if steps == 0: + return self.currentIndex() + + mode = self.playbackMode() + + if mode == self.CurrentItemOnce: + return -1 + elif mode == self.CurrentItemInLoop: + return self.currentIndex() + elif mode == self.Sequential: + next_pos = self.currentIndex() + steps + return next_pos if next_pos < self.mediaCount() else -1 + elif mode == self.Loop: + return (self.currentIndex() + steps) % self.mediaCount() + elif mode == self.Random: + raise NotImplementedError # TODO: Support Random mode + + def previousIndex(self, steps: int = 1) -> int: + if self.mediaCount() == 0: + return -1 + + if steps == 0: + return self.currentIndex() + + mode = self.playbackMode() + + if mode == self.CurrentItemOnce: + return -1 + elif mode == self.CurrentItemInLoop: + return self.currentIndex() + elif mode == self.Sequential: + if self.currentIndex() == -1: + prev_pos = self.mediaCount() - steps + else: + prev_pos = self.currentIndex() - 1 + + return prev_pos if prev_pos >= 0 else -1 + elif mode == self.Loop: + prev_pos = self.currentIndex() - steps + + while prev_pos < 0: + prev_pos += self.mediaCount() + + return prev_pos + elif mode == self.Random: + raise NotImplementedError # TODO: Support Random mode + + def next(self): + self._jump(self.nextIndex()) + + def previous(self): + self._jump(self.previousIndex()) + + def media(self, index: int) -> QMediaContent: + if index == -1: + return QMediaContent() + + media_id = self._get_media_id(index) + return self._media[media_id] + + def mediaCount(self) -> int: + return len(self._media) + + def clear(self): + raise NotImplementedError + + def shuffle(self): + raise NotImplementedError + + def load(self, *args, **kwargs): + raise NotImplementedError + + def save(self, *args, **kwargs): + raise NotImplementedError diff --git a/project/ui/__init__.py b/project/ui/__init__.py new file mode 100644 index 00000000..793816ee --- /dev/null +++ b/project/ui/__init__.py @@ -0,0 +1,11 @@ +from project.ui.captcha_dialogue import Ui_CaptchaDialogue as CaptchaDialogue +from project.ui.create_password import Ui_CreatePassword as CreatePassword +from project.ui.main_window import Ui_MainWindow as MainWindow +from project.ui.password_prompt import Ui_PasswordPrompt as PasswordPrompt +from project.ui.remove_dialogue import Ui_RemoveDialogue as RemoveDialogue +from project.ui.seek_dialogue import Ui_SeekDialogue as SeekDialogue + +__all__ = ( + "CaptchaDialogue", "CreatePassword", "MainWindow", "PasswordPrompt", "RemoveDialogue", + "SeekDialogue" +) diff --git a/project/ui/captcha_dialogue.py b/project/ui/captcha_dialogue.py new file mode 100644 index 00000000..5e7b03c3 --- /dev/null +++ b/project/ui/captcha_dialogue.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'qt/captchadialogue.ui', +# licensing of 'qt/captchadialogue.ui' applies. +# +# Created: Sun Mar 3 12:16:41 2019 +# by: pyside2-uic running on PySide2 5.12.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_CaptchaDialogue(object): + def setupUi(self, CaptchaDialogue): + CaptchaDialogue.setObjectName("CaptchaDialogue") + CaptchaDialogue.setWindowModality(QtCore.Qt.ApplicationModal) + CaptchaDialogue.resize(300, 275) + CaptchaDialogue.setMinimumSize(QtCore.QSize(300, 275)) + self.gridLayout = QtWidgets.QGridLayout(CaptchaDialogue) + self.gridLayout.setSpacing(5) + self.gridLayout.setContentsMargins(15, 15, 15, 15) + self.gridLayout.setObjectName("gridLayout") + self.captcha_view = QtWidgets.QGraphicsView(CaptchaDialogue) + self.captcha_view.setObjectName("captcha_view") + self.gridLayout.addWidget(self.captcha_view, 0, 0, 1, 3) + self.buttons = QtWidgets.QDialogButtonBox(CaptchaDialogue) + self.buttons.setOrientation(QtCore.Qt.Horizontal) + self.buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttons.setCenterButtons(True) + self.buttons.setObjectName("buttons") + self.gridLayout.addWidget(self.buttons, 6, 0, 1, 3) + self.captcha_input = QtWidgets.QLineEdit(CaptchaDialogue) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.captcha_input.sizePolicy().hasHeightForWidth()) + self.captcha_input.setSizePolicy(sizePolicy) + self.captcha_input.setObjectName("captcha_input") + self.gridLayout.addWidget(self.captcha_input, 3, 1, 1, 1) + spacerItem = QtWidgets.QSpacerItem(20, 10, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + self.gridLayout.addItem(spacerItem, 1, 0, 1, 3) + spacerItem1 = QtWidgets.QSpacerItem(20, 10, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + self.gridLayout.addItem(spacerItem1, 4, 0, 1, 3) + spacerItem2 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + self.gridLayout.addItem(spacerItem2, 3, 0, 1, 1) + spacerItem3 = QtWidgets.QSpacerItem(20, 20, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + self.gridLayout.addItem(spacerItem3, 3, 2, 1, 1) + self.input_label = QtWidgets.QLabel(CaptchaDialogue) + self.input_label.setScaledContents(False) + self.input_label.setAlignment(QtCore.Qt.AlignCenter) + self.input_label.setMargin(2) + self.input_label.setObjectName("input_label") + self.gridLayout.addWidget(self.input_label, 2, 0, 1, 3) + + self.retranslateUi(CaptchaDialogue) + QtCore.QMetaObject.connectSlotsByName(CaptchaDialogue) + + def retranslateUi(self, CaptchaDialogue): + CaptchaDialogue.setWindowTitle(QtWidgets.QApplication.translate("CaptchaDialogue", "CAPTCHA", None, -1)) + self.input_label.setText(QtWidgets.QApplication.translate("CaptchaDialogue", "Enter the characters displayed above:", None, -1)) + diff --git a/project/ui/create_password.py b/project/ui/create_password.py new file mode 100644 index 00000000..78db7587 --- /dev/null +++ b/project/ui/create_password.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'qt/createpassword.ui', +# licensing of 'qt/createpassword.ui' applies. +# +# Created: Sun Mar 3 14:56:24 2019 +# by: pyside2-uic running on PySide2 5.12.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_CreatePassword(object): + def setupUi(self, CreatePassword): + CreatePassword.setObjectName("CreatePassword") + CreatePassword.setWindowModality(QtCore.Qt.ApplicationModal) + CreatePassword.resize(400, 300) + CreatePassword.setMinimumSize(QtCore.QSize(400, 300)) + CreatePassword.setModal(True) + self.formLayout = QtWidgets.QFormLayout(CreatePassword) + self.formLayout.setContentsMargins(10, 10, 10, 10) + self.formLayout.setSpacing(15) + self.formLayout.setObjectName("formLayout") + self.instructions = QtWidgets.QTextBrowser(CreatePassword) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.instructions.sizePolicy().hasHeightForWidth()) + self.instructions.setSizePolicy(sizePolicy) + self.instructions.setObjectName("instructions") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.SpanningRole, self.instructions) + self.password_label = QtWidgets.QLabel(CreatePassword) + self.password_label.setMargin(4) + self.password_label.setObjectName("password_label") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.password_label) + self.password = QtWidgets.QLineEdit(CreatePassword) + self.password.setEchoMode(QtWidgets.QLineEdit.Password) + self.password.setObjectName("password") + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.password) + self.confirm_label = QtWidgets.QLabel(CreatePassword) + self.confirm_label.setMargin(4) + self.confirm_label.setObjectName("confirm_label") + self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.confirm_label) + self.confirm_password = QtWidgets.QLineEdit(CreatePassword) + self.confirm_password.setEchoMode(QtWidgets.QLineEdit.Password) + self.confirm_password.setObjectName("confirm_password") + self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.confirm_password) + self.error_message = QtWidgets.QTextBrowser(CreatePassword) + self.error_message.setObjectName("error_message") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.SpanningRole, self.error_message) + self.buttons = QtWidgets.QDialogButtonBox(CreatePassword) + self.buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttons.setObjectName("buttons") + self.formLayout.setWidget(4, QtWidgets.QFormLayout.SpanningRole, self.buttons) + + self.retranslateUi(CreatePassword) + QtCore.QMetaObject.connectSlotsByName(CreatePassword) + + def retranslateUi(self, CreatePassword): + CreatePassword.setWindowTitle(QtWidgets.QApplication.translate("CreatePassword", "Form", None, -1)) + self.instructions.setHtml(QtWidgets.QApplication.translate("CreatePassword", "\n" +"
\n" +"Enter what you want your new password to be. To confirm what you entered, type your password backwards again.
", None, -1)) + self.password_label.setText(QtWidgets.QApplication.translate("CreatePassword", "Password:", None, -1)) + self.confirm_label.setText(QtWidgets.QApplication.translate("CreatePassword", "Confirm:", None, -1)) + self.error_message.setHtml(QtWidgets.QApplication.translate("CreatePassword", "\n" +"\n" +"Time remaining for the current track, in hexadecimal seconds.
", None, -1)) + self.previous_button.setText(QtWidgets.QApplication.translate("MainWindow", "Previous", None, -1)) + self.play_button.setText(QtWidgets.QApplication.translate("MainWindow", "Play", None, -1)) + self.next_button.setText(QtWidgets.QApplication.translate("MainWindow", "Next", None, -1)) + self.menu_file.setTitle(QtWidgets.QApplication.translate("MainWindow", "File", None, -1)) + self.add_files_action.setText(QtWidgets.QApplication.translate("MainWindow", "Add files", None, -1)) + diff --git a/project/ui/password_prompt.py b/project/ui/password_prompt.py new file mode 100644 index 00000000..e820b9c2 --- /dev/null +++ b/project/ui/password_prompt.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'qt/passwordprompt.ui', +# licensing of 'qt/passwordprompt.ui' applies. +# +# Created: Sun Mar 3 15:23:43 2019 +# by: pyside2-uic running on PySide2 5.12.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_PasswordPrompt(object): + def setupUi(self, PasswordPrompt): + PasswordPrompt.setObjectName("PasswordPrompt") + PasswordPrompt.resize(350, 250) + PasswordPrompt.setMinimumSize(QtCore.QSize(300, 250)) + self.formLayout = QtWidgets.QFormLayout(PasswordPrompt) + self.formLayout.setContentsMargins(10, 10, 10, 10) + self.formLayout.setSpacing(10) + self.formLayout.setObjectName("formLayout") + self.instructions = QtWidgets.QTextBrowser(PasswordPrompt) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.instructions.sizePolicy().hasHeightForWidth()) + self.instructions.setSizePolicy(sizePolicy) + self.instructions.setObjectName("instructions") + self.formLayout.setWidget(0, QtWidgets.QFormLayout.SpanningRole, self.instructions) + self.password_label = QtWidgets.QLabel(PasswordPrompt) + self.password_label.setMargin(4) + self.password_label.setObjectName("password_label") + self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.password_label) + self.password_input = QtWidgets.QLineEdit(PasswordPrompt) + self.password_input.setEchoMode(QtWidgets.QLineEdit.Password) + self.password_input.setObjectName("password_input") + self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.password_input) + self.confirm_instructions = QtWidgets.QTextBrowser(PasswordPrompt) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.confirm_instructions.sizePolicy().hasHeightForWidth()) + self.confirm_instructions.setSizePolicy(sizePolicy) + self.confirm_instructions.setObjectName("confirm_instructions") + self.formLayout.setWidget(4, QtWidgets.QFormLayout.SpanningRole, self.confirm_instructions) + self.confirm_label = QtWidgets.QLabel(PasswordPrompt) + self.confirm_label.setMargin(4) + self.confirm_label.setObjectName("confirm_label") + self.formLayout.setWidget(6, QtWidgets.QFormLayout.LabelRole, self.confirm_label) + self.confirm_input = QtWidgets.QLineEdit(PasswordPrompt) + self.confirm_input.setEchoMode(QtWidgets.QLineEdit.Password) + self.confirm_input.setObjectName("confirm_input") + self.formLayout.setWidget(6, QtWidgets.QFormLayout.FieldRole, self.confirm_input) + self.error = QtWidgets.QLabel(PasswordPrompt) + self.error.setText("") + self.error.setObjectName("error") + self.formLayout.setWidget(8, QtWidgets.QFormLayout.SpanningRole, self.error) + self.buttons = QtWidgets.QDialogButtonBox(PasswordPrompt) + self.buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttons.setObjectName("buttons") + self.formLayout.setWidget(9, QtWidgets.QFormLayout.SpanningRole, self.buttons) + + self.retranslateUi(PasswordPrompt) + QtCore.QMetaObject.connectSlotsByName(PasswordPrompt) + + def retranslateUi(self, PasswordPrompt): + PasswordPrompt.setWindowTitle(QtWidgets.QApplication.translate("PasswordPrompt", "Enter Password", None, -1)) + self.instructions.setHtml(QtWidgets.QApplication.translate("PasswordPrompt", "\n" +"\n" +"As a security precaution, please enter the password.
", None, -1)) + self.password_label.setText(QtWidgets.QApplication.translate("PasswordPrompt", "Password", None, -1)) + self.confirm_instructions.setHtml(QtWidgets.QApplication.translate("PasswordPrompt", "\n" +"\n" +"Please reorder the password into alphabetical order.
", None, -1)) + self.confirm_label.setText(QtWidgets.QApplication.translate("PasswordPrompt", "Password", None, -1)) + diff --git a/project/ui/remove_dialogue.py b/project/ui/remove_dialogue.py new file mode 100644 index 00000000..90553fa7 --- /dev/null +++ b/project/ui/remove_dialogue.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'qt/removedialogue.ui', +# licensing of 'qt/removedialogue.ui' applies. +# +# Created: Sun Mar 3 15:53:35 2019 +# by: pyside2-uic running on PySide2 5.12.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_RemoveDialogue(object): + def setupUi(self, RemoveDialogue): + RemoveDialogue.setObjectName("RemoveDialogue") + RemoveDialogue.setWindowModality(QtCore.Qt.WindowModal) + RemoveDialogue.resize(409, 125) + RemoveDialogue.setMinimumSize(QtCore.QSize(400, 125)) + RemoveDialogue.setModal(True) + self.grid = QtWidgets.QGridLayout(RemoveDialogue) + self.grid.setSpacing(10) + self.grid.setContentsMargins(10, 10, 10, 10) + self.grid.setObjectName("grid") + self.buttons = QtWidgets.QDialogButtonBox(RemoveDialogue) + self.buttons.setOrientation(QtCore.Qt.Horizontal) + self.buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttons.setCenterButtons(True) + self.buttons.setObjectName("buttons") + self.grid.addWidget(self.buttons, 2, 0, 1, 1) + self.label = QtWidgets.QLabel(RemoveDialogue) + self.label.setAlignment(QtCore.Qt.AlignCenter) + self.label.setMargin(4) + self.label.setObjectName("label") + self.grid.addWidget(self.label, 0, 0, 1, 1) + self.input = QtWidgets.QLineEdit(RemoveDialogue) + self.input.setObjectName("input") + self.grid.addWidget(self.input, 1, 0, 1, 1) + + self.retranslateUi(RemoveDialogue) + QtCore.QObject.connect(self.buttons, QtCore.SIGNAL("accepted()"), RemoveDialogue.accept) + QtCore.QObject.connect(self.buttons, QtCore.SIGNAL("rejected()"), RemoveDialogue.reject) + QtCore.QMetaObject.connectSlotsByName(RemoveDialogue) + + def retranslateUi(self, RemoveDialogue): + RemoveDialogue.setWindowTitle(QtWidgets.QApplication.translate("RemoveDialogue", "Confirm Media Removal", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("RemoveDialogue", "Please enter the title of the media to confirm removal:", None, -1)) + diff --git a/project/ui/seek_dialogue.py b/project/ui/seek_dialogue.py new file mode 100644 index 00000000..70f3a6c7 --- /dev/null +++ b/project/ui/seek_dialogue.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'qt/seekdialogue.ui', +# licensing of 'qt/seekdialogue.ui' applies. +# +# Created: Sun Mar 3 05:41:17 2019 +# by: pyside2-uic running on PySide2 5.12.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_SeekDialogue(object): + def setupUi(self, SeekDialogue): + SeekDialogue.setObjectName("SeekDialogue") + SeekDialogue.resize(300, 200) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(SeekDialogue.sizePolicy().hasHeightForWidth()) + SeekDialogue.setSizePolicy(sizePolicy) + SeekDialogue.setMinimumSize(QtCore.QSize(300, 200)) + self.gridLayout = QtWidgets.QGridLayout(SeekDialogue) + self.gridLayout.setObjectName("gridLayout") + self.grid = QtWidgets.QGridLayout() + self.grid.setSpacing(5) + self.grid.setContentsMargins(5, 5, 5, 5) + self.grid.setObjectName("grid") + self.hour_label = QtWidgets.QLabel(SeekDialogue) + font = QtGui.QFont() + font.setPointSize(12) + font.setWeight(75) + font.setBold(True) + self.hour_label.setFont(font) + self.hour_label.setAlignment(QtCore.Qt.AlignCenter) + self.hour_label.setMargin(4) + self.hour_label.setObjectName("hour_label") + self.grid.addWidget(self.hour_label, 1, 0, 1, 1) + self.sec_label = QtWidgets.QLabel(SeekDialogue) + font = QtGui.QFont() + font.setPointSize(12) + font.setWeight(75) + font.setBold(True) + self.sec_label.setFont(font) + self.sec_label.setAlignment(QtCore.Qt.AlignCenter) + self.sec_label.setMargin(4) + self.sec_label.setObjectName("sec_label") + self.grid.addWidget(self.sec_label, 1, 2, 1, 1) + self.hour_dial = QtWidgets.QDial(SeekDialogue) + self.hour_dial.setObjectName("hour_dial") + self.grid.addWidget(self.hour_dial, 2, 0, 1, 1) + self.min_dial = QtWidgets.QDial(SeekDialogue) + self.min_dial.setMaximum(59) + self.min_dial.setObjectName("min_dial") + self.grid.addWidget(self.min_dial, 2, 1, 1, 1) + self.buttons = QtWidgets.QDialogButtonBox(SeekDialogue) + self.buttons.setOrientation(QtCore.Qt.Horizontal) + self.buttons.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok) + self.buttons.setCenterButtons(True) + self.buttons.setObjectName("buttons") + self.grid.addWidget(self.buttons, 5, 0, 1, 3) + self.min_label = QtWidgets.QLabel(SeekDialogue) + font = QtGui.QFont() + font.setPointSize(12) + font.setWeight(75) + font.setBold(True) + self.min_label.setFont(font) + self.min_label.setAlignment(QtCore.Qt.AlignCenter) + self.min_label.setMargin(4) + self.min_label.setObjectName("min_label") + self.grid.addWidget(self.min_label, 1, 1, 1, 1) + self.min_lcd = QtWidgets.QLCDNumber(SeekDialogue) + self.min_lcd.setDigitCount(2) + self.min_lcd.setMode(QtWidgets.QLCDNumber.Oct) + self.min_lcd.setObjectName("min_lcd") + self.grid.addWidget(self.min_lcd, 3, 1, 1, 1) + self.sec_dial = QtWidgets.QDial(SeekDialogue) + self.sec_dial.setMaximum(59) + self.sec_dial.setInvertedAppearance(True) + self.sec_dial.setInvertedControls(False) + self.sec_dial.setObjectName("sec_dial") + self.grid.addWidget(self.sec_dial, 2, 2, 1, 1) + self.sec_lcd = QtWidgets.QLCDNumber(SeekDialogue) + self.sec_lcd.setDigitCount(2) + self.sec_lcd.setMode(QtWidgets.QLCDNumber.Oct) + self.sec_lcd.setObjectName("sec_lcd") + self.grid.addWidget(self.sec_lcd, 3, 2, 1, 1) + self.hour_lcd = QtWidgets.QLCDNumber(SeekDialogue) + self.hour_lcd.setDigitCount(3) + self.hour_lcd.setMode(QtWidgets.QLCDNumber.Oct) + self.hour_lcd.setObjectName("hour_lcd") + self.grid.addWidget(self.hour_lcd, 3, 0, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 15, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.grid.addItem(spacerItem, 4, 0, 1, 3) + self.gridLayout.addLayout(self.grid, 0, 0, 1, 1) + + self.retranslateUi(SeekDialogue) + QtCore.QObject.connect(self.buttons, QtCore.SIGNAL("accepted()"), SeekDialogue.accept) + QtCore.QObject.connect(self.buttons, QtCore.SIGNAL("rejected()"), SeekDialogue.reject) + QtCore.QMetaObject.connectSlotsByName(SeekDialogue) + + def retranslateUi(self, SeekDialogue): + SeekDialogue.setWindowTitle(QtWidgets.QApplication.translate("SeekDialogue", "Seek", None, -1)) + self.hour_label.setText(QtWidgets.QApplication.translate("SeekDialogue", "Hours", None, -1)) + self.sec_label.setText(QtWidgets.QApplication.translate("SeekDialogue", "Seconds", None, -1)) + self.min_label.setText(QtWidgets.QApplication.translate("SeekDialogue", "Minutes", None, -1)) + self.min_lcd.setToolTip(QtWidgets.QApplication.translate("SeekDialogue", "The minutes, in octal, of the position to seek.", None, -1)) + self.sec_lcd.setToolTip(QtWidgets.QApplication.translate("SeekDialogue", "The seconds, in octal, of the position to seek.", None, -1)) + self.hour_lcd.setToolTip(QtWidgets.QApplication.translate("SeekDialogue", "The hours, in octal, of the position to seek.", None, -1)) + diff --git a/project/widgets/__init__.py b/project/widgets/__init__.py new file mode 100644 index 00000000..26d68e1e --- /dev/null +++ b/project/widgets/__init__.py @@ -0,0 +1,11 @@ +from project.widgets.captcha_dialogue import CaptchaDialogue +from project.widgets.create_password import CreatePassword +from project.widgets.main_window import MainWindow +from project.widgets.password_prompt import PasswordPrompt +from project.widgets.remove_dialogue import RemoveDialogue +from project.widgets.seek_dialogue import SeekDialogue + +__all__ = ( + "CaptchaDialogue", "CreatePassword", "MainWindow", "PasswordPrompt", "RemoveDialogue", + "SeekDialogue" +) diff --git a/project/widgets/captcha_dialogue.py b/project/widgets/captcha_dialogue.py new file mode 100644 index 00000000..e8a64e3f --- /dev/null +++ b/project/widgets/captcha_dialogue.py @@ -0,0 +1,59 @@ +import random + +from captcha.image import ImageCaptcha +from PySide2.QtCore import QByteArray +from PySide2.QtGui import QPixmap +from PySide2.QtWidgets import QDialog, QGraphicsScene + +from project import ui + + +CHARACTERS = "2345679ADEFGHJLMNQRTabdefgjmnqr" + + +class CaptchaDialogue(QDialog): + def __init__(self, *args, **kwargs): + super(CaptchaDialogue, self).__init__(*args, **kwargs) + + self.ui = ui.CaptchaDialogue() + self.ui.setupUi(self) + + self.captcha_generator = ImageCaptcha() + self.scene = QGraphicsScene() + self.ui.captcha_view.setScene(self.scene) + + self.text = None + + self.ui.buttons.accepted.connect(self.accept) + self.ui.buttons.rejected.connect(self.reject) + + def open(self): + self.generate_captcha() + super().open() + + def done(self, result: QDialog.DialogCode): + if result == QDialog.Accepted and self.ui.captcha_input.text() != self.text: + self.ui.input_label.setText("Incorrect CAPTCHA given. Try again:") + self.ui.input_label.setStyleSheet("color: red") + self.generate_captcha() + else: + self.ui.input_label.setText("Enter the characters displayed above:") + self.ui.input_label.setStyleSheet("") + self.ui.captcha_input.clear() + super().done(result) + + def generate_captcha(self): + self.text = self._generate_text() + image_bytes = self.captcha_generator.generate(self.text).getvalue() + image = QByteArray(image_bytes) + + pixmap = QPixmap() + pixmap.loadFromData(image, "png") + self.scene.clear() + self.scene.addPixmap(pixmap) + + @staticmethod + def _generate_text() -> str: + """Return a string of random characters to be used for CAPTCHA generation.""" + sample = random.sample(CHARACTERS, 6) + return "".join(sample) diff --git a/project/widgets/create_password.py b/project/widgets/create_password.py new file mode 100644 index 00000000..21f62866 --- /dev/null +++ b/project/widgets/create_password.py @@ -0,0 +1,70 @@ +import sys + +from PySide2.QtWidgets import QDialog + +from project import ui + + +class CreatePassword(QDialog): + def __init__(self, *args, **kwargs): + super(CreatePassword, self).__init__(*args, **kwargs) + + self.ui = ui.CreatePassword() + self.ui.setupUi(self) + + self.ui.buttons.accepted.connect(self.accept) + self.ui.buttons.rejected.connect(self.reject) + self.new_password = "" + + def closeEvent(self, event): + sys.exit() + + def open(self): + self.new_password = "" + super().open() + + def exec_(self): + self.new_password = "" + super().exec_() + + def done(self, result: QDialog.DialogCode): + if result == QDialog.Accepted: + errors = self._check() + if errors: + self.ui.error_message.setText(f"{errors}") + return + + self.new_password = self.ui.password.text() + self.ui.error_message.clear() + self.ui.password.clear() + self.ui.confirm_password.clear() + + super().done(result) + + def _check(self) -> str: + """Check if the password is valid and return any error messages to be displayed.""" + password = self.ui.password.text() + other = self.ui.confirm_password.text() + error_message = "" + + if len(password) < 10 or len(password) > 12: + error_message += "Length of password must be between 10 and 12.\n" + + has_number, has_character, has_special = False, False, False + for c in password: + if c in "1234567890": + has_number = True + if c in "~!@#$%^&*()_+{}|:'<>?`-=\\;\",./[]": + has_special = True + if c.lower() in "abcdefghijklmnopqrstuvwxyz": + has_character = True + if not has_number: + error_message += "Password must have at least one number.\n" + if not has_character: + error_message += "Password must have at least one alphabetical character.\n" + if not has_special: + error_message += "Password must have at least one special character.\n" + if password != other[::-1]: + error_message += "Confirmation must be backwards of the password." + + return error_message diff --git a/project/widgets/main_window.py b/project/widgets/main_window.py new file mode 100644 index 00000000..5088c071 --- /dev/null +++ b/project/widgets/main_window.py @@ -0,0 +1,212 @@ +import logging +import random + +from PySide2.QtCore import QPoint, Qt +from PySide2.QtGui import QMouseEvent +from PySide2.QtSql import QSqlTableModel +from PySide2.QtWidgets import ( + QAction, QDialog, QFileDialog, QHeaderView, QMainWindow, QMenu, QMessageBox +) + +from project import media, ui +from project.delegates import CurrentMediaDelegate +from project.widgets.captcha_dialogue import CaptchaDialogue +from project.widgets.password_prompt import PasswordPrompt +from project.widgets.remove_dialogue import RemoveDialogue +from project.widgets.seek_dialogue import SeekDialogue + +log = logging.getLogger(__name__) + + +class MainWindow(QMainWindow): + def __init__(self, password, *args, **kwargs): + super(MainWindow, self).__init__(*args, **kwargs) + + self.ui = ui.MainWindow() + self.ui.setupUi(self) + + self.password_prompt = PasswordPrompt(password, self) + self.password_prompt.accepted.connect(self.play_pressed) + + self.captcha_dialogue = CaptchaDialogue(self) + self.captcha_dialogue.finished.connect(self.add_files) + + self.seek_dialogue = SeekDialogue(self) + self.seek_dialogue.finished.connect(self.seek_finished) + + self.remove_dialogue = RemoveDialogue(self) + + self.playlist_model = self.create_model() + self.configure_view() + self.current_delegate = CurrentMediaDelegate() + + self.player = media.Player(self.playlist_model) + self.player.durationChanged.connect(self.ui.seek_slider.setMaximum) + self.player.durationChanged.connect(self.seek_dialogue.update_duration) + self.player.positionChanged.connect(self.ui.seek_slider.setValue) + self.player.positionChanged.connect(self.update_time_remaining) + self.player.stateChanged.connect(self.toggle_button_text) + + # Style the current row + header = self.ui.playlist_view.horizontalHeader() + header.sortIndicatorChanged.connect(self.style_current_row) + self.player.currentMediaChanged.connect(self.style_current_row) + self.player.media_added.connect(self.style_current_row) + self.player.media_removed.connect(self.style_current_row) + + self.ui.seek_slider.mousePressEvent = self.seek_slider_pressed # Override the event + + # Signal connections + self.ui.play_button.pressed.connect(self.prompt_for_password) + self.ui.previous_button.pressed.connect(self.player.playlist().previous) + self.ui.next_button.pressed.connect(self.player.playlist().next) + self.ui.add_files_action.triggered.connect(self.captcha_dialogue.open) + self.ui.volume_slider.valueChanged.connect(self.player.setVolume) + + @staticmethod + def create_model() -> QSqlTableModel: + """Create and return the model to use with the playlist table view.""" + model = QSqlTableModel() + model.setTable("playlist") + model.setEditStrategy(QSqlTableModel.OnManualSubmit) + + model.setHeaderData(1, Qt.Horizontal, "Title", Qt.DisplayRole) + model.setHeaderData(2, Qt.Horizontal, "Artist", Qt.DisplayRole) + model.setHeaderData(3, Qt.Horizontal, "Album", Qt.DisplayRole) + model.setHeaderData(4, Qt.Horizontal, "Genre", Qt.DisplayRole) + model.setHeaderData(5, Qt.Horizontal, "Date", Qt.DisplayRole) + + # Default is a descending sort, which leads to an inconsistency given media is appended + model.setSort(0, Qt.AscendingOrder) + model.select() # Force-update the view + + return model + + def configure_view(self): + """Configure the playlist table view.""" + self.ui.playlist_view.setModel(self.playlist_model) + self.ui.playlist_view.customContextMenuRequested.connect(self.show_view_context_menu) + self.ui.playlist_view.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.ui.playlist_view.hideColumn(0) # id + self.ui.playlist_view.hideColumn(6) # crc32 + self.ui.playlist_view.hideColumn(7) # path + + def create_view_context_menu(self, pos: QPoint) -> QMenu: + """Create and return a context menu to use with the playlist view.""" + menu = QMenu() + remove_action = QAction("Remove", self.ui.playlist_view) + menu.addAction(remove_action) + + row = self.ui.playlist_view.rowAt(pos.y()) + remove_action.triggered.connect(lambda: self.remove_triggered(row)) + + return menu + + def show_view_context_menu(self, pos: QPoint): + """Display a context menu for the table view.""" + menu = self.create_view_context_menu(pos) + self.ui.playlist_view.context_menu = menu # Prevents it from being GC'd + + global_pos = self.ui.playlist_view.mapToGlobal(pos) + menu.popup(global_pos) + + def remove_triggered(self, row: int): + """Show confirmation message boxes and remove the media at `row`.""" + title = self.playlist_model.index(row, 1).data() + + result_1 = QMessageBox.question( + self, + "Remove Media", + f"Are you sure you want to remove {title}?" + ) + + if result_1 != QMessageBox.Yes: + return + + result_2 = QMessageBox.question( + self, + "Remove Media", + f"Are you really sure?", + QMessageBox.Yes | QMessageBox.No + ) + + if result_2 != QMessageBox.Yes: + return + + result_3 = QMessageBox.warning( + self, + "Remove Media", + f"Are you really really sure?", + QMessageBox.Yes | QMessageBox.No + ) + + if result_3 != QMessageBox.Yes: + return + + self.remove_dialogue.title = title + self.remove_dialogue.exec_() + + if self.remove_dialogue.result() == QDialog.Accepted: + self.player.remove_media(row) + + def update_time_remaining(self, position: int): + """Update the time remaining for the current track on the LCD.""" + remaining = self.player.duration() - position + self.ui.media_time_lcd.display(remaining // 1000) + + def seek_slider_pressed(self, event: QMouseEvent): + """Open the seek dialogue when the slider is left clicked.""" + if self.player.playlist().currentMedia().isNull(): + return + + if event.button() == Qt.LeftButton: + pos = self.player.position() + self.seek_dialogue.update_position(pos) + self.seek_dialogue.open() + + def seek_finished(self, result: QDialog.DialogCode): + """Seek to the selected position if the seek dialogue was accepted.""" + if result == QDialog.DialogCode.Accepted: + pos = self.seek_dialogue.get_position() + self.player.setPosition(pos) + + def prompt_for_password(self): + """30% chance to open the password prompt before pressing play.""" + if random.randint(0, 100) <= 30: + self.password_prompt.open() + else: + self.play_pressed() + + def play_pressed(self): + """Play or pause the player depending on its state.""" + if self.player.state() in (media.Player.StoppedState, media.Player.PausedState): + self.player.play() + else: + self.player.pause() + + def toggle_button_text(self, state: media.Player.State): + """Set the text of the play button depending on the player's state.""" + if state in (media.Player.StoppedState, media.Player.PausedState): + self.ui.play_button.setText("Play") + else: + self.ui.play_button.setText("Pause") + + def style_current_row(self, *args): + """Set a custom delegate for the row corresponding to the current media.""" + current_row = self.player.playlist().currentIndex() + + # Clear custom delegate from any other rows + for row in range(self.playlist_model.rowCount()): + self.ui.playlist_view.setItemDelegateForRow(row, None) + + if current_row == -1: + return + + # TODO: Clear delegate for previous row + self.ui.playlist_view.setItemDelegateForRow(current_row, self.current_delegate) + + def add_files(self, result: QDialog.DialogCode): + """Show a file dialogue and add selected files to the playlist.""" + if result == QDialog.DialogCode.Accepted: + paths, _ = QFileDialog.getOpenFileNames(self, "Add files", "", "") + self.player.add_media(paths) diff --git a/project/widgets/password_prompt.py b/project/widgets/password_prompt.py new file mode 100644 index 00000000..c4d81cfc --- /dev/null +++ b/project/widgets/password_prompt.py @@ -0,0 +1,36 @@ +from PySide2.QtWidgets import QDialog + +from project import ui + + +class PasswordPrompt(QDialog): + def __init__(self, password, *args, **kwargs): + super(PasswordPrompt, self).__init__(*args, **kwargs) + + self.ui = ui.PasswordPrompt() + self.ui.setupUi(self) + + self.password = password + + self.ui.buttons.accepted.connect(self.accept) + self.ui.buttons.rejected.connect(self.reject) + + def done(self, result: QDialog.DialogCode): + if result == QDialog.Accepted: + if not self._check(): + self.ui.error.setText( + f"Passphrase incorrect. Try again." + ) + return + + self.ui.error.clear() + self.ui.password_input.clear() + self.ui.confirm_input.clear() + + super().done(result) + + def _check(self) -> bool: + """Check and return if the entered password is correct.""" + regular = self.ui.password_input.text() + confirm = self.ui.confirm_input.text() + return regular == self.password and confirm == "".join(sorted(self.password)) diff --git a/project/widgets/remove_dialogue.py b/project/widgets/remove_dialogue.py new file mode 100644 index 00000000..6c938d2e --- /dev/null +++ b/project/widgets/remove_dialogue.py @@ -0,0 +1,27 @@ +from PySide2.QtWidgets import QDialog + +from project import ui + + +class RemoveDialogue(QDialog): + def __init__(self, *args, **kwargs): + super(RemoveDialogue, self).__init__(*args, **kwargs) + + self.ui = ui.RemoveDialogue() + self.ui.setupUi(self) + + self.title = None + + self.ui.buttons.accepted.connect(self.accept) + self.ui.buttons.rejected.connect(self.reject) + + def done(self, result: QDialog.DialogCode): + if result == QDialog.Accepted: + if self.title != self.ui.input.text(): + self.ui.input.setText(f"Incorrect, try again:") + return + + self.ui.input.clear() + self.ui.label.setText("Please enter the title of the media to confirm removal:") + + super().done(result) diff --git a/project/widgets/seek_dialogue.py b/project/widgets/seek_dialogue.py new file mode 100644 index 00000000..086b2f8c --- /dev/null +++ b/project/widgets/seek_dialogue.py @@ -0,0 +1,60 @@ +from PySide2.QtWidgets import QDialog + +from project import ui + +# Constants in milliseconds +HOUR = 3600000 +MINUTE = 60000 +SECOND = 1000 + + +class SeekDialogue(QDialog): + def __init__(self, *args, **kwargs): + super(SeekDialogue, self).__init__(*args, **kwargs) + + self.ui = ui.SeekDialogue() + self.ui.setupUi(self) + + self.ui.hour_dial.valueChanged.connect(lambda value: self.ui.hour_lcd.display(value)) + self.ui.min_dial.valueChanged.connect(lambda value: self.ui.min_lcd.display(value)) + self.ui.sec_dial.valueChanged.connect(lambda value: self.ui.sec_lcd.display(value)) + + def get_position(self) -> int: + """Return the selected position in milliseconds.""" + pos = self.ui.hour_dial.value() * HOUR + pos += self.ui.min_dial.value() * MINUTE + pos += self.ui.sec_dial.value() * SECOND + + return pos + + def update_duration(self, duration: int): + """Set the maximum value of the dials and toggles widgets accordingly.""" + if duration >= MINUTE: + self.ui.sec_dial.setMaximum(59) + self.ui.min_dial.setEnabled(True) + self.ui.min_lcd.setEnabled(True) + else: + self.ui.sec_dial.setMaximum(duration // SECOND) + self.ui.min_dial.setEnabled(False) + self.ui.min_lcd.setEnabled(False) + + if duration >= HOUR: + self.ui.min_dial.setMaximum(59) + self.ui.hour_dial.setEnabled(True) + self.ui.hour_dial.setEnabled(True) + else: + self.ui.min_dial.setMaximum(duration // MINUTE) + self.ui.hour_dial.setEnabled(False) + self.ui.hour_dial.setEnabled(False) + + self.ui.hour_dial.setMaximum(duration // HOUR) + + def update_position(self, position: int): + """Set the values of the dials.""" + hours, remainder = divmod(position, HOUR) + self.ui.hour_dial.setValue(hours) + + minutes, remainder = divmod(remainder, MINUTE) + self.ui.min_dial.setValue(minutes) + + self.ui.sec_dial.setValue(remainder // SECOND) diff --git a/qt/captchadialogue.ui b/qt/captchadialogue.ui new file mode 100644 index 00000000..1265003f --- /dev/null +++ b/qt/captchadialogue.ui @@ -0,0 +1,151 @@ + +