-
Notifications
You must be signed in to change notification settings - Fork 4
/
main.pyw
9281 lines (8054 loc) · 518 KB
/
main.pyw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
r''' >>> thisismygithub - 10/31/21 <<<
____ ____ __
/ __ \__ __/ __ \/ /___ ___ _____ _____
/ /_/ / / / / /_/ / / __ `/ / / / _ \/ ___/
/ ____/ /_/ / ____/ / /_/ / /_/ / __/ /
/_/ \__, /_/ /_/\__,_/\__, /\___/_/
/____/ /____/
>>> thisismygithub - 10/31/21 <<<
icon sources/inspirations https://www.pinclipart.com/maxpin/hxThoo/ + https://www.hiclipart.com/free-transparent-background-png-clipart-vuclz
https://youtu.be/P1qMAupb2_Y?t=2461 VLC devs talk about Qt problems -> making two windows actually behave as one?
TODO: need a way to deal with VLC registry edits
TODO: move browse dialogs + indeterminate_progress decorator to qthelpers/util?
TODO: better/more fleshed-out themes
TODO: can't change themes while minimized to system tray (warn with tray icon?)
TODO: live-themes option? (button that auto-refreshes themes every half-second for theme-editing)
TODO: make more "modern" looking icon like VLC's ios app icon?
TODO: event_manager -> "no signature found for builtin <built-in method emit of PyQt5.QtCore.pyqtBoundSignal object"
TODO: getting the padding of the QSlider groove
TODO: cropping finally finished. potential improvements:
- ctrl-drag for square crops (not finished right now, especially near edges)
- draw text on the QFrames, not on player (requires own paintEvent, ideally)
- use alternate ffmpeg cropping format (in_w:in_h, etc.) for certain scenarios
- delete crop stuff after turning crop mode off
- get rid of self.selection in favor of using crop_frames[0] and crop_frames[2] (left/right)?
- arrow keys/spinboxes for precise movement/cropping over the factored points (a new use for defactor_point())
- https://stackoverflow.com/questions/24831484/how-to-align-qpainter-drawtext-around-a-point-not-a-rectangle
- use QDockWidgets instead of frames
TODO: find interesting use for QtWidgets.QGraphicsScene()? (used in old widgets/main.py files for early crop tests)
TODO: improved or improvised status bar? -> half-width and/or custom widgets for things like playback speed, etc.
TODO: centralwidget's layout column stretch should be a customizable option
TODO: streamlined way to trim and concat multiple sections of a single video (or just a timeline)
TODO: what are media_lists? https://stackoverflow.com/questions/28440708/python-vlc-binding-playing-a-playlist
TODO: add alternate log signal for logging.error messages, like "FAILED:"
- log_slot is bad anyways since it disguises the actual origin of a log message
TODO: decrease max heights of progress slider/side timestamps or is 20 pixels a good middleground?
TODO: vlc-style playlists + playlist menu?
TODO: vlc dvd/network/capture device MRLs? easy to integrate? (dvd:///C:/Windows/system32/, screen://)
TODO: vlc VLsub (very cool subtitle search-utility) -> opensubtitles.org
TODO: vlc Media Information window with editable metadata + codec information? <- get/set/save_meta
TODO: vlc.py stuff to check out
- audio_get_channel/audio_set_channel
- VideoAdjustOption enum (hue/brightness/whatnot)
- AudioEqualizer class
- all_slave, but for un-saved audio tracks
- get_full_title_descriptions <- Get the full description of available titles.
- BROKEN? -> get_full_chapter_descriptions(i_chapters_of_title) <- Get full description of available chapters. @param index of title to query for chapters (uses current title if set to -1)
TODO: make use of .nfo files to expand auto-opening abilities for subtitles -> look at language + subtitle filename/format and find it
- VLC already auto-opens subtitle files if they contain the media file's name anywhere in their own name
- maybe check if media file has metadata and compare it against all non-matching .nfo files
TODO: tab-order no longer exists due to setting everything to ClickFocus (worthwhile sacrifice?)
- previousInFocusChain and NextInFocusChain might be usable for best of both worlds
TODO: upload-to-imgur button? NOTE: requires client-id and requests Session. perhaps a secret advanced option
- "plugin" support could be just loading python files before showing GUI
TODO: use "rotate" metadata to have a live preview of rotations (might cause too many issues)? (or use QMediaPlayer and rotate manually)
- VLC has a way to rotate videos natively (this may be involving either callbacks (hard) or filters (easy?))
TODO: custom rotate angle option (possibly difficult, ffmpeg has bizarre syntax for it)
TODO: more secure way of locking videos during editing? like with an actual file lock (QtCore.QLockFile)
optimization: identify the length of time required for each part of the startup process
optimization: are there too many QActions and lambdas?
optimization: use direct libvlc functions
optimization: remove translate flags from .ui file? maybe not a good idea
TODO: use qtawesome icons/fonts? https://pypi.org/project/QtAwesome/ <- qta-browser
TODO: enhanced playback speed option (context menu/menubar)?
TODO: playlists (adapt smart shuffle to them too)
TODO: playing online media like VLC
TODO: video_set_aspect_ratio and video_set_scale (this is for "zooming")
TODO: add setting to make total duration label show the remaining time instead
TODO: add settings for trim graphics?
TODO: more fullscreen settings? (alternate animation -> raise/lower instead of fade, separate idle timer, etc.)
TODO: finish marquee settings: drop shadow (VLC can control this, but I have no idea how), Color
- fade durations aren't working very well, I was forced to put arbitrary limits on the settings
TODO: vlc settings to add
- video/audio dependent raise/focus settings
- settings for allowing multiple instances? (you can already sorta do this)
- "enable time-stretching audio" -> fixes audio pitch changing with playback speed
- --start-paused, --no-start-paused
TODO: gstreamer | ffpyplayer https://matham.github.io/ffpyplayer/player.html
TODO: https://wiki.videolan.org/Documentation:Modules/alphamask/
TODO: WA_PaintUnclipped https://doc.qt.io/qt-5/qt.html#WidgetAttribute-enum
TODO: app.primaryScreenChanged.connect()
TODO: is there a way to add/modify libvlc options like "--gain" post-launch? media.add_option() is very limited
TODO: make logo in about window clickable and link to somewhere
TODO: "add subtitle file" but for video/audio tracks? (audio tracks supported, but VLC chooses not to use this)
TODO: formats that still don't trim correctly after save() rewrite: 3gp, ogv, mpg (used to trim inaccurately, now doesn't trim at all)
- trimming likely needs to not use libx264 and aac for every format
TODO: ram usage
- !!! if it fails, update checking adds 15mb of ram that never goes away (~42mb before -> 57mb after)
- if it doesn't fail, update checking still adds around 6mb on average
- QWIDGETMAX is locked behind a pointless 4mb ram library
- transition to lazy loading instead of loading everything all at once (about/cat dialogs finished)
- themes take up nearly 2mb of ram (is loading the logo the biggest issue? only 14kb size)
TODO: editing feature changes:
- text overlay (this would be awesome)
- "replace/add audio" -> add prompt to change volume of incoming file (always? as separate action? while holding modifier?)
- "add audio" -> option to literally add it as an audio track (with ffmpeg, not libvlc/add_slave)
- converting mp4 to gif
- add "compress" option
- add "speed" option (this is already implemented for audio files through the resize function)
- do math to chain audio-resizes to get around ffmpeg atempo 0.5-2.0 limitation (can be chained in single command)
- implement more image edits?
- ability to "hold" fades
TODO: should probe files be written/read as bytes? how much faster would that be?
- `json.detect_encoding()` would still be needed for actually parsing the files into JSON
TODO: MEDIUM PRIORITY:
DPI/scaling support
further polish cropping
confirm/increase stability for videos > 60fps (not yet tested)
trimming-support for more obscure formats
implement filetype associations
system tray icon's menu is just blank on linux
high-precision progress bar on non-1x speeds
TODO: LOW PRIORITY:
resize-snapping does not work on linux
implement concatenation for audio files
further reduce RAM usage
"Restore Defaults" button in settings window
see update change logs before installing
add way to distinguish base and forked repos
ability to skip updates
far greater UI customization
figure out the smallest feasible ffmpeg executable we can use without sacrificing major edit features (ffmpeg.dll?)
ability to continue playing media while minimized to system tray
forwards/backwards buttons currently only work when pressed over the player
ability to limit combination-edits (adding/replacing audio) to the shortest input (this exists in ffmpeg but breaks often)
lazy concatenate dialog seems to have a memory leak (it does not free up QVideoList's memory after deletion)
dropping files over taskbar button doesn't work
KNOWN ISSUES:
Likely unfixable:
frame-seeking near the very end of a video rarely works (set_time()/set_position()/next_frame() make no difference)
NOTE: ^ this was greatly improved (but not fully fixed) in libvlc 3.0.19
Low priority:
manually entering single %'s in config file for path names somehow ignores all nested folders
partially corrupt videos have unusual behavior when frame-seeking corrupted parts <- use player.next_frame()?
frame-seeking only a handful of frames after a video finishes and trying to play again sometimes causes brief freeze
player becomes slightly less responsive after repeatedly minimizing to system tray (noticable fraction-of-a-second delay when navigating)
resizing videos doesn't actually stretch them? or... it does? very strange behavior with phantom black bars that differ from player to player
rarely, concatenated videos will have a literal missing frame between clips, causing PyPlayer's background to appear for 1 frame
abnormally long delay opening first video after opening extremely large video (longer delay than in VLC) -> delay occurs at player.set_media
output-name lineEdit's placeholder text becomes invisible after setting a theme and then changing focus
libVLC does NOT like it when you disable the video track. lots of bugs
Medium priority:
resizing an audio file rarely stalls forever with no error (works upon retry) NOTE: might have been fixed with new `Edit` class
rotating/flipping video rarely fails for no reason (works upon retry) NOTE: might have been fixed with new `Edit` class
videos with replaced/added audio tracks longer than the video themselves do NOT concatenate correctly (audio track freaks ffmpeg out)
repeatedly going into fullscreen on a higher DPI/scaled monitor results ruins the controls (general DPI/scaling support is high priority)
audio replacement/addition sometimes cuts out audio 1 second short. keyframe related? corrupted streams (not vlc-specific)?
Moderately high priority:
.3gp, .ogv, and .mpg files do not trim correctly
Cannot reproduce consistently:
volume gain suddenly changes after extended use
spamming the cycle buttons may rarely crash to desktop with no error NOTE: might have been fixed with 3/06/24 commit
spamming the cycle buttons may rarely cause the UI to desync in a way that `high_precision_progress_thread()` seemingly can't detect
player's current visible frame doesn't change when navigating (<- and ->) after video finishes until it's unpaused (used to never happen)
scrubbing slider and sharply moving mouse out of window while releasing causes video to not unpause until scrubbed again (rare)
clicking on the progress bar will update the video without moving the progress bar (very rare, may be bottlenecking issue)
fullscreen mode becomes partially unresponsive -> no idle timer, no passthrough on dockControls (very rare, may be bottlenecking issue)
system tray icon suddenly crashes -> "OSError: exception: access violation writing 0x0000000000000007" (extremely rare, unknown cause)
random freeze and crash to desktop after otherwise successful ffmpeg operation (rare, never leaves any error)
libvlc randomly jumps to a very, very low progress after otherwise successful navigation (very rare, unknown cause)
'''
from __future__ import annotations
import config
import widgets
import qtstart
import constants
import qthelpers
from constants import SetProgressContext
from bin.window_pyplayer import Ui_MainWindow
from bin.window_settings import Ui_settingsDialog
from util import ( # direct import time-sensitive utils for a very small optimization
add_path_suffix, ffmpeg, ffmpeg_async, foreground_is_fullscreen, get_font_path,
get_hms, get_PIL_Image, get_ratio_string, get_unique_path, get_verbose_timestamp,
open_properties, remove_dict_value, remove_dict_values, sanitize, scale, setctime,
suspend_process, kill_process, file_is_hidden
)
import os
import gc
import sys
import math
import glob
import json
import random
import logging
import subprocess
from time import sleep, localtime, mktime, strftime, strptime
from time import time as get_time # from time import time -> time() errors out
from threading import Thread
from traceback import format_exc
from contextlib import contextmanager
import filetype # 0.4mb ram
from vlc import State
from tinytag import TinyTag
from PyQt5 import QtCore, QtGui
from PyQt5 import QtWidgets as QtW
from PyQt5.QtCore import Qt
#from PyQt5.Qt import QWIDGETSIZE_MAX # PyQt5.Qt adds FOUR MB of ram usage, used in set_fullscreen
WindowStateChange = QtCore.QEvent.WindowStateChange # important alias, but can't be defined in __name__=='__main__'
# keyboard event: ['device', 'event_type', 'is_keypad', 'modifiers', 'name', 'scan_code', 'time', 'to_json' -> {"event_type": "up", "scan_code": 77, "name": "right", "time": 1636583589.5201082, "is_keypad": false}]
# Qt keyReleaseEvent: ['accept', 'count', 'ignore', 'isAccepted', 'isAutoRepeat', 'key', 'matches', 'modifiers', 'nativeModifiers', 'nativeScanCode', 'nativeVirtualKey', 'registerEventType', 'setAccepted', 'setTimestamp', 'spontaneous', 'text', 'timestamp', 'type']
# Qt wheelEvent: ['accept', 'angleDelta', 'buttons', 'globalPos', 'globalPosF', 'globalPosition', 'globalX', 'globalY', 'ignore', 'inverted', 'isAccepted', 'modifiers', 'phase', 'pixelDelta', 'pos', 'posF', 'position', 'registerEventType', 'setAccepted', 'setTimestamp', 'source', 'spontaneous', 'timestamp', 'type', 'x', 'y']
# Qt mousePressEvent: ['accept', 'button' (CORRECT), 'buttons' (WRONG), 'flags', 'globalPos', 'globalX', 'globalY', 'ignore', 'isAccepted', 'localPos', 'modifiers', 'pos', 'registerEventType', 'screenPos', 'setAccepted', 'setTimestamp', 'source', 'spontaneous', 'timestamp', 'type', 'windowPos', 'x', 'y'] windowPos, x, y -> -> actual pos inside WIDGET
# Qt mouseMoveEvent: ['accept', 'button', 'buttons', 'flags', 'globalPos', 'globalX', 'globalY', 'ignore', 'isAccepted', 'localPos', 'modifiers', 'pos', 'registerEventType', 'screenPos', 'setAccepted', 'setTimestamp', 'source', 'spontaneous', 'timestamp', 'type', 'windowPos', 'x', 'y']
# Qt dropEvent: ['accept', 'acceptProposedAction', 'dropAction', 'ignore', 'isAccepted', 'keyboardModifiers', 'mimeData', 'mouseButtons', 'pos', 'posF', 'possibleActions', 'proposedAction', 'registerEventType', 'setAccepted', 'setDropAction', 'source', 'spontaneous', 'type']
# QSize: ['boundedTo', 'expandedTo', 'grownBy', 'height', 'isEmpty', 'isNull', 'isValid', 'scale', 'scaled', 'setHeight', 'setWidth', 'shrunkBy', 'transpose', 'transposed', 'width']]
# QCursor: ['bitmap', 'hotSpot', 'mask', 'pixmap', 'pos', 'setPos', 'setShape', 'shape', 'swap']
# QScreen: [https://doc.qt.io/qt-5/qscreen.html -> size, availableSize, refreshRate, name, physicalSize, orientation (0 default, 2 landscape), primaryOrientation, nativeOrientation]
# --> app.screens(), app.primaryScreen(), app.primaryScreenChanged.connect()
# Qt.KeyboardModifiers | QApplication.keyboardModifiers() | QApplication.queryKeyboardModifiers()
# QColor F suffix is Float -> values are represented from 0-1. (getRgb() becomes getRgbF())
# self.childAt(x, y) | self.underMouse() -> bool
# NOTE: QtWidgets.QToolTip.hideText/showText/setPalette/setFont (showText is laggy)
# NOTE: app.setQuitOnLastWindowClosed(False) -> app.quit() (app.aboutToQuit.connect)
# NOTE: Plugins (QPluginLoader/QLibrary): Felgo (really good), QSkinny (lightweight), Advanced Docking System (laggy but pure Qt)
# Qt Pdf Viewer Library, CircularSlider (QML), GitQlient, All KDE Community plugins
# NOTE: Useful: QReadWriteLock/QLockFile, QStorageInfo, QStandardPaths, QFileSystemWatcher, QMimeData (for dragging)
# NOTE: Potentially useful: QMutex, QLocale, QStateMachine(?), QShow/HideEvent, QFileSystemWatcher, QPdfDocument (paid?)
# NOTE: Interesting: QFileselector, QCamera, QEventLoop[Locker], QWinEventNotifier, QColorTransform(??), QSharedMemory (inter-process)
# NOTE: Interesting but useless in Python: QSaveFile, QRandomGenerator, QTemporaryDir/File, QJsonObject
# -------------------------------------------
# Additional media-related utility functions
# -------------------------------------------
def probe_files(*files: str, refresh: bool = False, write: bool = True, retries: int = 0) -> dict[str, dict]:
''' Probes an indeterminant number of `files` and returns a dictionary of
`{path: probe_dictionary}` pairs. All files are probed concurrently, but
this function does not return until all probes are completed. Files that
fail are simply not included.
This function is similar to the probing process in `self.open()`
(but is not used there for performance reasons) - by default, it will
create/validate/reuse probe files. However, if `refresh` is True, a new
probe will always be generated even if the probe file already exists.
If `write` is False, any new probes will not be written to a file. '''
try:
logging.info(f'Manually probing files: {files} (refresh={refresh})')
probes: dict[str, dict] = {}
processes: list[tuple[str, str, subprocess.Popen]] = []
is_windows = constants.IS_WINDOWS
if not is_windows:
import shlex # have to pass commands as list for linux/macos (stupid)
cmd_parts = shlex.split(f'"{FFPROBE}" -show_format -show_streams -of json "output"')
# begin probe-process for each file and immediately jump to the next file
for file in files:
if file in probes or not exists(file):
continue
stat = os.stat(file)
probe_file = f'{constants.PROBE_DIR}{sep}{os.path.basename(file)}_{stat.st_mtime}_{stat.st_size}.txt'
probe_exists = exists(probe_file)
if probe_exists:
if refresh: # NOTE: if `refresh` is True and `write` is False, existing...
try: os.remove(probe_file) # ...probe files will be deleted without being replaced
except: logging.warning('(!) FAILED TO DELETE UNWANTED PROBE FILE: ' + format_exc())
probe_exists = False
else:
with open(probe_file, 'r', encoding='utf-8') as probe:
try:
probe_data = parse_json(probe.read())
if not probe_data:
raise AssertionError('probe returned no data')
probes[file] = probe_data
except:
probe.close()
logging.info('(?) Deleting potentially invalid probe file: ' + probe_file)
try: os.remove(probe_file)
except: logging.warning('(!) FAILED TO DELETE POTENTIALLY INVALID PROBE FILE: ' + format_exc())
probe_exists = False
if not probe_exists:
if is_windows:
cmd = f'"{FFPROBE}" -show_format -show_streams -of json "{file}"'
else: # ^ do NOT use ">" here since we need to read stdout
cmd = cmd_parts[:] # copy list and replace final element with our destination
cmd[-1] = file # do NOT put quotes around this
processes.append(
(
file,
probe_file,
subprocess.Popen(
cmd,
text=True, # decodes stdout into text rather than a byte stream
encoding='utf-8', # use ffmpeg/ffprobe's encoding so `text=True` doesn't crash for paths w/ scary characters
errors='ignore', # drop bad characters when there's an encoding error (which won't matter for our usecase)
stdout=subprocess.PIPE, # don't use `shell=True` for the same reason as above
startupinfo=constants.STARTUPINFO
) # ^ hides the command prompt that appears w/o `shell=True`
)
)
# for any files that did not have pre-existing probe files, wait until...
# ...their processes are complete and read output directly from the process
for file, probe_file, process in processes:
try:
out, err = process.communicate() # NOTE: this is where errors happen on filenames with the wrong encoding above
probe_data = parse_json(out)
if not probe_data:
raise AssertionError('probe returned no data')
probes[file] = probe_data
if write: # manually write probe to file
with open(probe_file, 'w', encoding='utf-8') as probe:
probe.write(out) # ^ DON'T use `errors='ignore'` here. if we somehow error out here, i'd rather know why
except:
logging.warning(f'(!) {file} could not be correctly parsed by FFprobe: {format_exc()}')
show_on_statusbar(f'{file} could not be correctly parsed by FFprobe.')
return probes
# if we're low on RAM, wait one second and try again
except OSError: # "[WinError 1455] The paging file is too small for this operation to complete"
logging.warning(f'(!) OSError while probing files: {format_exc()}')
if retries:
show_on_statusbar('(!) Not enough RAM to probe files. Trying again...')
sleep(1)
return probe_files(*files, refresh, write, retries - 1)
else:
show_on_statusbar('(!) Not enough RAM to probe files. Giving up.')
return {}
def get_audio_duration(file: str) -> float:
''' Lightweight way of getting the duration of an audio `file`.
Used for instances where we need ONLY the duration. '''
try:
try: # https://pypi.org/project/tinytag/0.18.0/
return TinyTag.get(file, tags=False).duration
except: # TinyTag is lightweight but cannot handle everything
import music_tag # only import music_tag if we absolutely need to
return music_tag.load_file(file)['#length'].value
except: # this is to handle things that wrongly report as audio, like .ogv files
log_on_statusbar('(?) File could not be read as an audio file (not recognized by TinyTag or music_tag)')
return 0.0
@contextmanager
def get_image_data(path: str, extension: str = None):
# TODO I don't need this anymore and should probably avoid using it at all.
try:
if exists(path): image_data = get_PIL_Image().open(path, formats=(extension,) if extension else None)
else: image_data = get_PIL_Image().fromqpixmap(image_player.art)
yield image_data
finally:
try: image_data.close()
except: logging.warning('(?) Image pointer could not be closed (it likely was never open in the first place).')
@contextmanager
def get_PIL_safe_path(original_path: str, final_path: str):
# TODO Like the above, this is a holdover from when I was reworking
# operation ordering/chaining for 0.6.0 and is not actually needed
# anymore, save for one spot where I was too lazy to implement Pillow.
try:
temp_path = ''
if splitext_media(final_path, constants.IMAGE_EXTENSIONS)[-1] == '':
good_ext = splitext_media(original_path, constants.IMAGE_EXTENSIONS)[-1]
if good_ext == '':
good_ext = '.png'
temp_path = final_path + good_ext
yield temp_path
else:
yield final_path
finally:
if temp_path != '':
try: os.replace(temp_path, final_path)
except: logging.warning('(!) FAILED TO RENAME TEMPORARY IMAGE PATH' + format_exc())
def splitext_media(
path: str,
valid_extensions: tuple[str] = constants.ALL_MEDIA_EXTENSIONS,
invalid_extensions: tuple[str] = constants.ALL_MEDIA_EXTENSIONS,
*,
strict: bool = True,
period: bool = True
) -> tuple[str, str]:
''' Split the extension from a `path` if the extension is within a
list of `valid_extensions`. If not, the basename is returned with an
empty extension. The extension will be lowercase. If `period` is True,
the preceding period will be included (i.e. ".mp4"). If `strict` is
False, an unknown extension can still be returned intact if:
1. It is not within a list of `invalid_extensions`
2. It is 6 characters or shorter
3. It contains at least one letter
4. It does not contain anything other than letters and numbers
NOTE: `strict` must be provided as a keyword argument.
NOTE: `valid_extensions` is evaluated first. `invalid_extensions` should
rarely be changed, but may be passed as None/False/"" if desired. '''
base, ext = os.path.splitext(path)
ext = ext.lower()
# if no ext to begin with, return immediately
if not ext:
return path, ''
# if `strict` is False and ext is invalid, only return if ext is >6 characters
if ext not in valid_extensions:
if strict or len(ext) > 6 or ext in (invalid_extensions or tuple()):
return base, ''
# verify ext has at least one letter and no symbols
has_letters = False
for c in ext[1:]:
if c.isalpha():
has_letters = True
elif not c.isdigit():
return base, ''
if not has_letters:
return base, ''
# return extension with or without preceding period (".mp4" vs "mp4")
if period:
return base, ext
return base, ext[1:]
def close_handle(handle, delete: bool): # i know they're not really handles but whatever
''' Closes a file-object `handle` and attempts
to `delete` its associated path. '''
handle.close()
if delete and exists(handle.name):
try: os.remove(handle.name)
except: logging.warning(f'(!) Failed to delete dummy file at final destination ({handle.name}): {format_exc()}')
#def correct_misaligned_formats(audio, video) -> str: # this barely works
# _, vext = os.path.splitext(video)
# abase, aext = os.path.splitext(audio)
# if vext != aext and not (vext == '.mp4' and aext == '.mp3'):
# new_audio = f'{abase}{vext}' # create new audio filename if extensions don't match
# logging.info(f'Formats misaligned between audio "{audio}" and video "{video}". Correcting audio to "{new_audio}"')
# ffmpeg(None, f'-i "{audio}" "{new_audio}"') # convert audio to video's format
# audio = new_audio # replace bad audio filename
# else: logging.info(f'Formats aligned between audio "{audio}" and video "{video}".')
# return audio
# ---------------------
# Editing helper class
# ---------------------
class Edit:
''' A class for handling, executing, and tracking edits in progress. '''
__slots__ = (
'dest', 'temp_dest', 'process', '_is_paused', '_is_cancelled',
'_threads', 'has_priority', 'frame_rate', 'frame_count',
'audio_track_titles', 'operation_count', 'operations_started', 'frame',
'value', 'text', 'percent_format', 'start_text', 'override_text'
)
def __init__(self, dest: str = ''):
self.dest = dest
self.temp_dest = ''
self.process: subprocess.Popen = None
self._is_paused = False
self._is_cancelled = False
self._threads = 0
self.has_priority = False
self.frame_rate = 0.0
self.frame_count = 0
self.audio_track_titles: list[str] = []
self.operation_count = 1
self.operations_started = 0
self.frame = 0
self.value = 0
self.text = 'Saving'
self.percent_format = '(%p%)'
self.start_text = 'Saving'
self.override_text = False
@property
def is_paused(self) -> bool:
''' Use `self.pause()` to safely alter this property. '''
return self._is_paused
@property
def is_cancelled(self) -> bool:
''' Use `self.cancel()` to safely cancel. '''
return self._is_cancelled
def pause(self, paused: bool = None) -> bool:
''' Suspends or resumes the edit's FFmpeg process. If `paused` is
not provided, the current pause-state is toggled instead. '''
# if `paused` is not provided, just toggle our current pause state
will_pause = (not self._is_paused) if paused is None else paused
# NOTE: on Windows, suspending a process STACKS!!! i.e. if you suspend a process...
# ...twice, you must resume it twice -> ONLY suspend if `self._is_paused` will change
if will_pause != self._is_paused:
self._is_paused = will_pause # ↓ returns None if process hasn't terminated yet
if self.process and self.process.poll() is None:
suspend_process(self.process, suspend=will_pause)
if self.has_priority:
self.set_progress_bar(value=self.value)
return will_pause
def cancel(self):
''' Cancels this edit by killing its current FFmpeg process.
Resumes process first if it was previously suspended. '''
self._is_cancelled = True
if constants.IS_WINDOWS: # NOTE: don't have to actually unpause on Windows...
self._is_paused = False # ...since we don't rely on stdout buffering
else:
self.pause(paused=False)
def give_priority(self, update_others: bool = True, ignore_lock: bool = False, conditional: bool = False):
''' Refreshes progress bar/taskbar to this edit's values if we've been
given priority over updating the progress bar. If `update_others`
is True, all other edits in `gui.edits_in_progress` will set their
`has_priority` property to False. This method returns immediately
if `gui.lock_edit_priority` is True and `ignore_lock` is False,
or if `conditional` is True and any other edit has priority. '''
# return immediately if desired
if gui.lock_edit_priority and not ignore_lock:
return
if conditional:
for edit in gui.edits_in_progress:
if edit.has_priority:
return
# ensure priority is disabled on everything else
if update_others:
for edit in gui.edits_in_progress:
edit.has_priority = False
self.has_priority = True
if self.frame == 0: # assume we haven't parsed any output yet
gui.set_save_progress_value_and_format_signal.emit(0, self.start_text)
refresh_title()
else:
self.set_progress_bar(value=self.value)
gui.set_save_progress_max_signal.emit(100 if self.frame_count else 0)
def get_progress_text(self, frame: int = 0, simple: bool = False) -> str:
''' Returns `self.text` surrounded by relevant information, e.g. "2
edits in progress - Trimming [1/3] (25%)". Manually replaces %v/%m
with `frame`/`self.frame_count`. If `self.frame_count` is 0, "?" is
used instead. If `simple` is provided, a standardized format that
ignores edit counts/percent formats/text overrides is returned. '''
if simple:
text = self.text
percent_format = f'({self.value}%)'
elif self.override_text:
return self.text
else:
percent_format = self.percent_format
save_count = len(gui.edits_in_progress)
if save_count > 1:
text = f'{save_count} edits in progress - {self.text}'
else:
text = self.text
# handle operation count and pause symbol for this edit
operation_count = self.operation_count
if operation_count > 1:
pause = '𝗜𝗜, ' if self._is_paused else ''
text = f'{text} [{pause}{self.operations_started}/{operation_count}] {percent_format}'
else:
pause = ' [𝗜𝗜] ' if self._is_paused else ' '
text = f'{text}{pause}{percent_format}'
# return with `QProgressBar` variables manually replaced TODO: never used, no plans -> why even bother?
return text.replace('%v', str(frame)).replace('%m', str(self.frame_count or '?'))
def set_progress_bar(self, frame: int = None, value: int = None) -> int:
''' Sets the progress bar/taskbar button to `frame`/`Edit.frame_count`.
Updates the progress bar's text and puts the average progress of all
edits/operations in the titlebar. Returns the new percentage. '''
if value is None:
value = int((frame / max(1, self.frame_count)) * 100)
self.value = value
self.frame = frame or self.frame
# update progress bar, taskbar, and titlebar with our current value/text
if self.has_priority:
gui.set_save_progress_value_and_format_signal.emit(value, self.get_progress_text(frame))
if constants.IS_WINDOWS and settings.checkTaskbarProgressEdit.isChecked():
gui.taskbar_progress.setValue(value)
refresh_title()
return value
def ffmpeg(
self,
infile: str,
cmd: str,
outfile: str = None,
text: str = None,
start_text: str = None,
percent_format: str = None,
text_override: str = None,
auto_map_tracks: bool = True,
audio_track_titles: list[str] = None
) -> str:
''' Executes an FFmpeg `cmd` on `infile` and outputs to `outfile`,
showing a progress bar on both the statusbar and the taskbar icon
(on Windows) by parsing FFmpeg's output. "%in" and "%out" will be
replaced within `cmd` if provided. "%out" will be appended to the
end of `cmd` automatically if needed. If `auto_map_tracks` is True,
"-map 0" will be inserted before "%out" if "-map" is not present.
the appropriate metadata arguments for `audio_track_titles` (or
`self.audio_track_titles`) will also be inserted.
NOTE: `infile` and "%in" do not necessarily need to be included, but
if you don't providing `infile`, you shouldn't provide "%in" either.
NOTE: If `outfile` is not provided, `infile` is overwritten instead.
If neither was provided, an exception is raised.
NOTE: This method will only update the progress bar if this edit
has priority. Priority may change mid-operation and is gained
whenever `len(gui.edits_in_progress) == 1`.
`Edit.frame_rate` is a hint for the progress bar as to what frame
rate to use when normal frame-output from FFmpeg is not available
(such as for audio files) and we must convert timestamp-output to
frames instead. If not provided, `gui.frame_rate` is used.
`Edit.frame_count` is the target value that is used to calculate
our current progress percentage. If not provided and this operation
has priority, the progress bar switches to an indeterminate bar.
`text` specifies the main text that will appear on the progress bar
(while this edit has priority), surrounded by relevant information
such as how many other edits are in progress and how many operations
this edit has left. `start_text` (if provided) overrides `text`
until the first progress update is parsed, and `percent_format` is
the suffix that will be added to the end of `text`. It does not have
to be an actual percentage. `QProgressBar`'s format variables:
- %p - percent complete
- %v - raw current value (frame)
- %m - raw max value (frame count, or "?" if frame count is 0).
If `text_override` is provided (and this edit has priority), `text`,
`percent_format`, and `start_text` are all ignored, no other
information is added, and `Edit.override_text` is set to True.
NOTE: Temporary paths will be locked/unlocked if `infile` is
already locked when you call this method.
NOTE: This method used to optionally handle locking/unlocking and
cleanup, but these features have since been removed. Please handle
these things before/after calling this method (see: `gui._save()`).
Returns the actual final output path. '''
start = get_time()
locked_files = gui.locked_files
edits_in_progress = gui.edits_in_progress
had_priority = len(edits_in_progress) == 1
is_windows = constants.IS_WINDOWS
logging.info(f'Performing FFmpeg operation (infile={infile} | outfile={outfile} | cmd={cmd})')
# set title/text-format-related properties based on parameters and existing values
self.override_text = bool(text_override)
self.start_text = start_text or text_override or self.get_progress_text()
self.text = text_override or text or self.text
self.audio_track_titles = audio_track_titles or self.audio_track_titles
if percent_format is not None:
self.percent_format = percent_format
# prepare the progress bar/taskbar/titlebar if no other edits are active
if had_priority:
self.has_priority = True # ↓ must set value to actually show the progress bar
gui.set_save_progress_value_and_format_signal.emit(0, self.start_text)
gui.set_save_progress_max_signal.emit(100 if self.frame_count else 0)
gui.set_save_progress_visible_signal.emit(True)
if is_windows and settings.checkTaskbarProgressEdit.isChecked():
gui.taskbar_progress.reset()
refresh_title()
# validate `infile` if it was provided
if infile:
assert exists(infile), f'`infile` "{infile}" does not exist.'
if not outfile:
outfile = infile
logging.info(f'`outfile` not provided, setting to `infile`: {infile}')
elif not outfile:
raise AssertionError('Both `infile` and `outfile` are invalid. This FFmpeg command is impossible.')
try:
# create temp file if `infile` and `outfile` are the same (ffmpeg can't edit files in-place)
editing_in_place = False
if infile:
if infile == outfile: # NOTE: this happens in `gui._save()` w/ multiple operations
editing_in_place = True
temp_infile = add_path_suffix(infile, '_temp', unique=True)
if infile in locked_files: # if `infile` is already locked, lock the temp...
locked_files.add(temp_infile) # ...path too, regardless of our `lock` parameter
os.rename(infile, temp_infile) # rename `infile` to our temporary name
logging.info(f'Renamed "{infile}" to temporary FFmpeg file "{temp_infile}"')
else:
temp_infile = infile
else: # no infile provided at all, so no temp path either
temp_infile = ''
# replace %in and %out with their respective (quote-surrounded) paths
if '%out' not in cmd: # ensure %out is present so we have a spot to insert `outfile`
cmd += ' %out'
# insert `-map 0` so all tracks are "mapped" to the final output
# TODO: ffprobe can't parse track titles (LOL), so we need mediainfo if we want...
# ...to get them on the fly, especially for edits like concatenation. incredible.
if auto_map_tracks and '-map ' not in cmd:
out = cmd.find(' %out')
if self.audio_track_titles: # ffmpeg drops the track titles, so insert garbage metadata arguments
titles = ' '.join( # ↓ replace empty titles with something generic, like "Track 2"
f'-metadata:s:a:{index} title="{title.strip() or f"Track {index + 1}"}"'
for index, title in enumerate(self.audio_track_titles)
)
cmd = f'{cmd[:out]} -map 0 {titles}{cmd[out:]}'
else:
cmd = f'{cmd[:out]} -map 0{cmd[out:]}'
# run final ffmpeg command
try:
self._threads = settings.spinFFmpegThreads.value() if settings.checkFFmpegThreadOverride.isChecked() else 0
process: subprocess.Popen = ffmpeg_async(
cmd=cmd.replace('%in', f'"{temp_infile}"').replace('%out', f'"{outfile}"'),
priority=settings.comboFFmpegPriority.currentIndex(),
threads=self._threads
)
except:
logging.error(f'(!) FFMPEG FAILED TO OPEN: {format_exc()}')
raise # raise anyway so cleanup can occur
self.process = process
self.temp_dest = outfile
self.operations_started += 1
# update progress bar using the 'frame=???' lines from ffmpeg's stdout until ffmpeg is finished
# https://stackoverflow.com/questions/67386981/ffmpeg-python-tracking-transcoding-process/67409107#67409107
# TODO: 'total_size=', time spent, and operations remaining could also be shown (save_progress_bar.setFormat())
frame_rate = max(1, self.frame_rate or gui.frame_rate) # used when ffmpeg provides `out_time_ms` instead of `frame`
use_outtime = True
last_frame = 0
lines_read = 0
lines_to_log = []
while True:
if process.poll() is not None: # returns None if process hasn't terminated yet
break
# if we're paused, continue sleeping but refresh title every second if necessary
while self._is_paused:
sleep(1.0)
if len(edits_in_progress) > 1 and self.has_priority:
refresh_title()
# edit cancelled -> kill this thread's ffmpeg process and cleanup
if self._is_cancelled:
raise AssertionError('Cancelled.')
# check if this thread lost priority
if had_priority and not self.has_priority:
had_priority = False
# check if this thread was manually set to control the progress bar
if not had_priority and self.has_priority:
had_priority = True
self.give_priority()
# check if this thread should automatically start controlling the progress bar, then...
# ...sleep before parsing output -> sleep longer (update less frequently) while not visible
if self.has_priority:
sleep(0.5)
elif len(edits_in_progress) == 1: # NOTE: this doesn't actually get reached anymore i think
logging.info('(?) Old auto-priority-update code reached. This probably shouldn\'t be possible.')
had_priority = True
self.give_priority()
sleep(0.5)
else:
sleep(0.5) # split non-priority sleep into two parts so users can...
if not self.has_priority: # ...switch priority w/o too much delay before updates resume
sleep(0.5)
# seek to end of current stdout output then back again to calculate how much data...
# ...we'll need to read (we have to do it this way to get around pipe buffering)
if is_windows:
start_index = process.stdout.tell()
process.stdout.seek(0, 2)
end_index = process.stdout.tell()
try:
process.stdout.seek(start_index, 0) # seeking back sometimes throws an error?
progress_lines = process.stdout.read(end_index - start_index).split('\n')
except OSError:
logging.warning(f'(!) Failed to seek backwards from index {end_index} to index {start_index} in FFmpeg\'s stdout pipe, retrying...')
continue
except:
logging.warning('(!) Unexpected error while seeking or reading from FFmpeg\'s stdout pipe: ' + format_exc())
progress_lines = []
# can't seek in streams on linux -> call & measure readline()'s delay until it buffers
# NOTE: this is WAY less efficient and updates noticably slower when sleeping for the same duration. too bad lol
else:
progress_lines = []
while process.poll() is None: # ensure we don't try to read a new line if process already ended
line_read_start = get_time()
progress_lines.append(process.stdout.readline().strip())
if not progress_lines[-1] or get_time() - line_read_start > 0.05:
break
# >>> parse ffmpeg output <<<
# loop over new stdout output without waiting for buffer so we can read output in...
# ...batches and sleep between loops without falling behind, saving a lot of resources
new_frame = last_frame
for progress_line in progress_lines:
lines_read += 1
lines_to_log.append(f'FFmpeg output line #{lines_read}: {progress_line}')
if not progress_line:
logging.info('FFmpeg output a blank progress line to STDOUT, leaving progress loop...')
break
# check for common errors
if progress_line[-6:] == 'failed': # "malloc of size ___ failed"
if 'malloc of size' in progress_line:
raise AssertionError(progress_line)
elif 'do not match the corresponding output link' in progress_line:
raise AssertionError(progress_line) # ^ concating videos with different dimensions
# normal videos will have a "frame=" progress string
if progress_line[:6] == 'frame=':
frame = min(int(progress_line[6:].strip()), self.frame_count)
if last_frame == frame and frame == 1: # specific edits will constantly spit out "frame=1"...
use_outtime = True # ...for these scenarios, we should ignore frame output
else:
use_outtime = False # if we ARE using frames, don't use "out_time_ms" (less accurate)
new_frame = frame
# ffmpeg usually uses "out_time_ms" for audio files
elif use_outtime and progress_line[:12] == 'out_time_ms=':
try:
seconds = int(progress_line.strip()[12:-6])
new_frame = min(int(seconds * frame_rate), self.frame_count)
except ValueError:
pass
# update progress bar to latest new frame (so we don't spam updates while parsing stdout)
if new_frame != last_frame:
self.set_progress_bar(new_frame)
last_frame = new_frame
# batch-log all our newly read lines at once
if lines_to_log:
progress_lines = '\n'.join(lines_to_log)
logging.info(f'New FFmpeg output from {self}:\n{progress_lines}')
lines_to_log.clear()
# terminate process just in case ffmpeg got locked up at the end
try: process.terminate()
except: pass
# cleanup temp file, if needed (editing in place means we had to rename `infile`)
if editing_in_place:
qthelpers.deleteTempPath(temp_infile)
log_on_statusbar(f'FFmpeg operation succeeded after {get_verbose_timestamp(get_time() - start)}.')
return outfile
except Exception as error:
if lines_to_log:
progress_lines = '\n'.join(lines_to_log)
logging.info(f'Final FFmpeg output leading up to error {self}:\n{progress_lines}')
if str(error) == 'Cancelled.':
log_on_statusbar('Cancelling...')
logging.info(f'FFmpeg operation cancelled after {get_time() - start:.1f} seconds. Cleaning up...')
else:
log_on_statusbar(f'(!) FFmpeg operation failed after {get_verbose_timestamp(get_time() - start)}: {format_exc()}')
# TODO: is there ever a scenario we DON'T want to kill ffmpeg here? doing this lets us delete `temp_infile`
# TODO: add setting to NOT delete `temp_infile` on error? (here + `self._save()`)
if self.process:
kill_process(process) # aggressively terminate ffmpeg process in case it's still running
if editing_in_place:
qthelpers.deleteTempPath(temp_infile, 'FFmpeg file')
raise # raise exception anyway (we'll still go to the finally-statement)
finally:
if editing_in_place: # always unlock our temporary path if necessary
try:
locked_files.discard(temp_infile)
except:
pass
class Undo:
__slots__ = 'type', 'label', 'description', 'data'
def __init__(self, type_: constants.UndoType, label: str, description: str, data: dict):
self.type = type_
self.label = label
self.description = description
self.data = data
# TODO: add setting for max undos?
if len(gui.undo_dict) > 50:
for key in tuple(gui.undo_dict.items())[50:]:
try: del gui.undo_dict[key]
except: pass
# TODO: should we do this here, in `gui.refresh_undo_menu`, or save lambdas as a property (`undo.action()`)?
def execute(self):
''' Uses `self.data` to undo an action as defined by `self.type`.
If successful, we remove ourselves from `gui.undo_dict`. '''
try:
if self.type == constants.UndoType.RENAME:
if gui.undo_rename(self):
remove_dict_value(gui.undo_dict, self)
except:
log_on_statusbar(f'(!) Unexpected error while attempting undo: {format_exc()}')
# ---------------------
# Main GUI
# ---------------------
class GUI_Instance(QtW.QMainWindow, Ui_MainWindow):
# Custom signals MUST be class variables
# NOTE: avoid directly emitting signals prefixed with _ if possible
_open_cleanup_signal = QtCore.pyqtSignal()
_open_signal = QtCore.pyqtSignal(dict)
_open_external_command_signal = QtCore.pyqtSignal(str)
restart_signal = QtCore.pyqtSignal()
force_pause_signal = QtCore.pyqtSignal(bool)
restore_tracks_signal = QtCore.pyqtSignal(bool)
concatenate_signal = QtCore.pyqtSignal(QtW.QAction, list)
show_ffmpeg_warning_signal = QtCore.pyqtSignal(QtW.QWidget)
show_trim_dialog_signal = QtCore.pyqtSignal()
update_progress_signal = QtCore.pyqtSignal(int)
refresh_title_signal = QtCore.pyqtSignal()
set_save_progress_visible_signal = QtCore.pyqtSignal(bool)
set_save_progress_max_signal = QtCore.pyqtSignal(int)
set_save_progress_value_signal = QtCore.pyqtSignal(int)
set_save_progress_format_signal = QtCore.pyqtSignal(str)
set_save_progress_value_and_format_signal = QtCore.pyqtSignal(int, str)
disable_crop_mode_signal = QtCore.pyqtSignal(bool)
handle_updates_signal = QtCore.pyqtSignal(bool)
_handle_updates_signal = QtCore.pyqtSignal(dict, dict)
popup_signal = QtCore.pyqtSignal(dict)
log_on_statusbar_signal = QtCore.pyqtSignal(str)
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
self.app = app
self.setupUi(self)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # this allows easier clicking off of lineEdits
self.save_progress_bar = QtW.QProgressBar(self.statusbar)
self.dialog_settings = qthelpers.getDialogFromUiClass(Ui_settingsDialog, flags=Qt.WindowStaysOnTopHint)
if not constants.IS_WINDOWS: # settings dialog was designed around Windows UI
self.dialog_settings.resize(self.dialog_settings.tabWidget.sizeHint().width() + 32,
self.dialog_settings.height())
self.icons = {
'window': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}logo.ico'),
'settings': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}settings.png'),
'play': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}play.png'),
'pause': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}pause.png'),
'stop': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}stop.png'),
'restart': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}restart.png'),
'x': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}x.png'),
'loop': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}loop.png'),
'autoplay': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}autoplay.png'),
'autoplay_backward': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}autoplay_backward.png'),
'autoplay_shuffle': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}autoplay_shuffle.png'),
'cycle_forward': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}cycle_forward.png'),
'cycle_backward': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}cycle_backward.png'),
'reverse_vertical': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}reverse_vertical.png'),
'recent': QtGui.QIcon(f'{constants.RESOURCE_DIR}{os.sep}recent.png'),
}
self.setWindowIcon(self.icons['window'])
app.setWindowIcon(self.icons['window'])