-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathwidgets.py
4174 lines (3470 loc) · 211 KB
/
widgets.py
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
''' Various custom widgets for achieving complex behavior such as
the media progress slider, the concatenation dialog's list
items, draggable frames, advanced hotkey widgets, and more.
thisismy-github '''
from __future__ import annotations
from PyQt5 import QtGui, QtCore, QtMultimedia, QtMultimediaWidgets
from PyQt5.QtCore import Qt
from PyQt5 import QtWidgets as QtW
import qtstart
import constants
import qthelpers
from constants import SetProgressContext
from util import ffmpeg, ffmpeg_async, get_hms, get_PIL_Image, get_unique_path
import os
import time
import math
import logging
import vlc
from colour import Color
from threading import Thread
from traceback import format_exc
from vlc import State
# TODO: I absolutely cannot decide what should be camelCase and what should be underscores. It's becoming an issue.
# media_player: ['_as_parameter_', '_instance', 'add_slave', 'audio_get_channel', 'audio_get_delay', 'audio_get_mute', 'audio_get_track', 'audio_get_track_count', 'audio_get_track_description', 'audio_get_volume', 'audio_output_device_enum', 'audio_output_device_get', 'audio_output_device_set', 'audio_output_set', 'audio_set_callbacks', 'audio_set_channel', 'audio_set_delay', 'audio_set_format', 'audio_set_format_callbacks', 'audio_set_mute', 'audio_set_track', 'audio_set_volume', 'audio_set_volume_callback', 'audio_toggle_mute', 'can_pause', 'event_manager', 'from_param', 'get_agl', 'get_chapter', 'get_chapter_count', 'get_chapter_count_for_title', 'get_fps', 'get_full_chapter_descriptions', 'get_full_title_descriptions', 'get_fullscreen', 'get_hwnd', 'get_instance', 'get_length', 'get_media', 'get_nsobject', 'get_position', 'get_rate', 'get_role', 'get_state', 'get_time', 'get_title', 'get_title_count', 'get_xwindow', 'has_vout', 'is_playing', 'is_seekable', 'navigate', 'next_chapter', 'next_frame', 'pause', 'play', 'previous_chapter', 'program_scrambled', 'release', 'retain', 'set_agl', 'set_android_context', 'set_chapter', 'set_equalizer', 'set_evas_object', 'set_fullscreen', 'set_hwnd', 'set_media', 'set_mrl', 'set_nsobject', 'set_pause', 'set_position', 'set_rate', 'set_renderer', 'set_role', 'set_time', 'set_title', 'set_video_title_display', 'set_xwindow', 'stop', 'toggle_fullscreen', 'toggle_teletext', 'video_get_adjust_float', 'video_get_adjust_int', 'video_get_aspect_ratio', 'video_get_chapter_description', 'video_get_crop_geometry', 'video_get_cursor', 'video_get_height', 'video_get_logo_int', 'video_get_marquee_int', 'video_get_marquee_string', 'video_get_scale', 'video_get_size', 'video_get_spu', 'video_get_spu_count', 'video_get_spu_delay', 'video_get_spu_description', 'video_get_teletext', 'video_get_title_description', 'video_get_track', 'video_get_track_count', 'video_get_track_description', 'video_get_width', 'video_set_adjust_float', 'video_set_adjust_int', 'video_set_aspect_ratio', 'video_set_callbacks', 'video_set_crop_geometry', 'video_set_deinterlace', 'video_set_format', 'video_set_format_callbacks', 'video_set_key_input', 'video_set_logo_int', 'video_set_logo_string', 'video_set_marquee_int', 'video_set_marquee_string', 'video_set_mouse_input', 'video_set_scale', 'video_set_spu', 'video_set_spu_delay', 'video_set_subtitle_file', 'video_set_teletext', 'video_set_track', 'video_take_snapshot', 'video_update_viewpoint', 'will_play']
# media: ['_as_parameter_', '_instance', 'add_option', 'add_option_flag', 'add_options', 'duplicate', 'event_manager', 'from_param', 'get_duration', 'get_instance', 'get_meta', 'get_mrl', 'get_parsed_status', 'get_state', 'get_stats', 'get_tracks_info', 'get_type', 'get_user_data', 'is_parsed', 'parse', 'parse_async', 'parse_stop', 'parse_with_options', 'player_new_from_media', 'release', 'retain', 'save_meta', 'set_meta', 'set_user_data', 'slaves_add', 'slaves_clear', 'slaves_get', 'subitems', 'tracks_get']
# ------------------------------------------
# Logger
# ------------------------------------------
logger = logging.getLogger('widgets.py')
# ------------------------------------------
# Aliases (set in main.pyw)
# ------------------------------------------
gui: QtW.QMainWindow = None
app = QtW.qApp
cfg = None
settings = None
ZOOM_DYNAMIC_FIT = 0
ZOOM_NO_SCALING = 1
ZOOM_FIT = 2
ZOOM_FILL = 3
# ------------------------------------------
# Core Widgets
# ------------------------------------------
class PyPlayerBackend:
__name__ = 'Undefined'
SUPPORTS_PARSING = False # Can this player parse and return critical media info (fps, duration, etc.)?
SUPPORTS_VIDEO_TRACK_MANIPULATION = False # Can this player return video track info AND set video tracks?
SUPPORTS_AUDIO_TRACK_MANIPULATION = False # Can this player return audio track info AND set audio tracks?
SUPPORTS_SUBTITLE_TRACK_MANIPULATION = False # Can this player return subtitle track info AND set subtitle tracks?
SUPPORTS_AUTOMATIC_SUBTITLE_ENABLING = False # Does this player auto-enable subtitle tracks when present?
ENABLE_AUTOMATIC_TRACK_RESTORATION = True # Should PyPlayer restore its saved tracks/delays when opening/restoring? NOTE: Avoid this if possible!!
ENABLE_AUTOMATIC_TRACK_RESET = True # Should PyPlayer reset its saved tracks/delays to None when opening new files?
def __init__(self, parent, *args, **kwargs):
self.parent = parent
self.enabled = False
self.menu: QtW.QMenu = None
self.last_file = ''
self.file_being_opened = ''
self.open_cleanup_queued = False
self.last_text_settings: tuple[str, int, int] = None
# ---
def __getattr__(self, name: str):
''' Allows access to undefined player-specific properties.
This is mainly intended for testing purposes. '''
logger.info(f'Attempting to access undefined player-specific property `player.{name}`')
return getattr(self._player, name)
# ---
def enable(self) -> bool:
''' Called upon enabling the backend. When starting PyPlayer, this is
called immediately after `gui.setup()` and loading the config, but
before showing the window. '''
self.enabled = True
self.open_cleanup_queued = False
return True
def disable(self, wait: bool = True):
''' Called upon disabling the backend. Stop playing, set `self.enabled`
to False, perform cleanup, disconnect signals, and end all threads.
NOTE: If `wait` is False, do not wait for cleanup unless absolutely
necessary. Instead, have `self.enable()` wait for unfinished cleanup
before re-enabling. '''
self.enabled = False
self.open_cleanup_queued = False
self.stop()
def show(self):
pass
# ---
def on_show(self, event: QtGui.QShowEvent):
''' Called in `gui.showEvent()`, before the window's
state has been fully restored/validated. '''
self.show()
def on_resize(self, event: QtGui.QResizeEvent):
''' Called at the end of `QVideoPlayer.resizeEvent()`. '''
pass
def on_fullscreen(self, fullscreen: bool):
''' Called at the end of `gui.set_fullscreen()`, just before calling
`gui.showFullScreen()`/`gui.showMaximized()`/`gui.showNormal()`. '''
pass
def on_parse(self, file: str, base_mime: str, mime: str, extension: str):
''' Called at the end of `gui.parse_media_file()`. All probe-related
properties will be up-to-date when this event fires. Rarely, `mime`
may mutate - `base_mime` is what `file` was initially parsed as.
NOTE: This event MUST emit `gui._open_cleanup_signal` in some way.
If you do not emit it directly, set `self.open_cleanup_queued` to
True until the cleanup signal is emitted so PyPlayer understands
that it's waiting for cleanup.
NOTE: This event fires even if FFprobe finishes first, and the UI
will begin updating immediately afterwards. If you wish to override
probe properties, you must decide between waiting for the player
to finish parsing or using `self.on_open_cleanup()` instead. '''
gui._open_cleanup_signal.emit()
def on_open_cleanup(self):
''' Called at the end of `gui._open_cleanup_slot()`. All opening-related
properties (aside from `gui.open_in_progress`) will be up-to-date
when this even fires. '''
pass
def on_restart(self):
''' Called in `gui.restart()`, immediately after confirming the restart
is valid. `gui.restarted` will be False. After this event, the UI
be updated and the player will be force-paused. This event should
do any extraneous cleanup that must be urgently completed to ensure
finished media is immediately/seamlessly ready to play again. '''
gui.update_progress_signal.emit(gui.frame_count) # ensure UI snaps to final frame
# ---
def play(self, file: str, will_restore: bool = False) -> bool:
''' Opens the provided `file`, and begins *asynchronously* parsing
the media if supported by the player. `will_restore` will be
True if we're intending to set the progress to an arbitrary value
immediately afterwards (e.g. after a renaming or restart). '''
raise NotImplementedError()
def pause(self):
''' Toggles the pause state. '''
raise NotImplementedError()
def stop(self):
''' Stops the player, releasing any locks. '''
raise NotImplementedError()
def loop(self):
''' Loops the player back to `gui.minimum` after the media *completes*.
This is not called when there is an ending marker. '''
self.set_and_update_progress(gui.minimum, SetProgressContext.RESET_TO_MIN)
return gui.force_pause(False)
def snapshot(self, path: str, frame: int, width: int, height: int):
''' Saves the desired `frame` to `path`, resized to `width`x`height`.
NOTE: Do not return until the snapshot is complete and saved.
NOTE: You should probably override this. FFmpeg sucks. '''
w = width or -1 # -1 uses aspect ratio in ffmpeg
h = height or -1 # using `-ss` is faster but even less accurate
ffmpeg(f'-i "{gui.video}" -frames:v 1 -vf "select=\'eq(n\\,{frame})\', scale={w}:{h}" "{path}"')
# ---
def set_pause(self, paused: bool):
''' Sets the pause state to `paused`. '''
raise NotImplementedError()
def get_mute(self) -> bool:
''' Returns the mute state. '''
raise NotImplementedError()
def set_mute(self, muted: bool):
''' Sets the mute state to `muted`. '''
raise NotImplementedError()
def get_volume(self) -> int:
''' Returns the volume between 0-100. '''
raise NotImplementedError()
def set_volume(self, volume: int):
''' Sets the `volume` between 0-100 (or above). '''
raise NotImplementedError()
def get_playback_rate(self) -> float:
''' Returns the playback rate relative to 1.0. '''
return 1.0
def set_playback_rate(self, rate: float):
''' Sets the playback `rate` relative to 1.0. '''
self.show_text('Playback speed is not supported by the selected player.')
def get_position(self) -> float:
''' Returns the media's progress as a value between 0.0-1.0. '''
raise NotImplementedError()
def set_position(self, percent: float):
''' Sets media progress to a `percent` between 0.0-1.0. '''
raise NotImplementedError()
def set_frame(self, frame: int):
''' Called while frame-seeking (or entering an exact frame).
Use this if you have anything special you'd like to do
(e.g. libVLC's `next_frame()`). '''
self.set_and_update_progress(frame)
def set_and_update_progress(self, frame: int = 0, context: int = SetProgressContext.NONE):
''' Sets player position to `frame` and updates the UI accordingly.
`context` is useful if you need to do additional work depending
on the specific reason we're manually setting the position.
NOTE: Don't forget to update GIF progress.
NOTE: This method should update the player's non-GIF progress
ASAP, so the player feels "snappier" on the user's end. '''
self.set_position(frame / gui.frame_count)
gui.update_progress(frame)
gui.gifPlayer.gif.jumpToFrame(frame)
# ---
def get_state(self) -> int: # TODO
return State.NothingSpecial
def can_restart(self) -> bool:
''' Called at the start of `gui.restart()`.
Returns False if a restart should be skipped. '''
return True
def is_parsed(self) -> bool:
''' Returns True if the player has finished its own
parsing of the current media, independent of FFprobe. '''
return False
def is_playing(self) -> bool:
''' Semi-convenience method. Returns True if
we are actively playing unpaused media. '''
raise NotImplementedError()
def get_fps(self) -> float:
''' Returns the frame rate of the current
media if possible, otherwise 0.0. '''
return 0.0
def get_duration(self) -> float:
''' Returns the duration (in seconds) of the
current media if possible, otherwise 0.0. '''
return 0.0
def get_dimensions(self) -> tuple[int, int]:
''' Returns the dimensions of the current media as a tuple if possible,
otherwise `(0, 0)`. This method may raise an exception. '''
return 0, 0
# ---
def get_audio_delay(self) -> int:
return 0
def set_audio_delay(self, msec: int):
self.show_text('Audio delays are not supported by the selected player.')
def get_subtitle_delay(self) -> int:
return 0
def set_subtitle_delay(self, msec: int):
self.show_text('Subtitle delays are not supported by the selected player.')
def get_video_track(self) -> int:
return -1
def get_audio_track(self) -> int:
return -1
def get_subtitle_track(self) -> int:
return -1
def get_video_tracks(self, raw: bool = False) -> tuple[int, str]:
''' Generator that yields each video track's ID and title as a
tuple. If `raw` is True, the title should be yielded as-is. '''
yield -1, 'The selected player does not support tracks'
def get_audio_tracks(self, raw: bool = False) -> tuple[int, str]:
''' Generator that yields each audio track's ID and title as a
tuple. If `raw` is True, the title should be yielded as-is. '''
yield -1, 'The selected player does not support tracks'
def get_subtitle_tracks(self, raw: bool = False) -> tuple[int, str]:
''' Generator that yields each subtitle track's ID and title as a
tuple. If `raw` is True, the title should be yielded as-is. '''
yield -1, 'The selected player does not support tracks'
def get_video_track_count(self) -> int:
return 1
def get_audio_track_count(self) -> int:
return 1
def get_subtitle_track_count(self) -> int:
return 1
def set_video_track(self, index: int):
self.show_text('Track manipulation is not supported by the selected player.')
def set_audio_track(self, index: int):
self.show_text('Track manipulation is not supported by the selected player.')
def set_subtitle_track(self, index: int):
self.show_text('Track manipulation is not supported by the selected player.')
def add_audio_track(self, url: str, enable: bool = False) -> bool:
self.show_text('Dynamically adding audio tracks is not supported by the selected player.')
def add_subtitle_track(self, url: str, enable: bool = False) -> bool:
self.show_text('Dynamically adding subtitle tracks is not supported by the selected player.')
# ---
def show_text(self, text: str, timeout: int = 350, position: int = None):
''' Displays marquee `text` (overlaying the player),
overriding the default `position` if desired. '''
if not settings.groupText.isChecked(): return # marquees are completely disabled -> return
gui.statusbar.showMessage(text.replace('%%', '%'), max(timeout, 1000))
def set_text_position(self, button: QtW.QRadioButton):
''' Sets marquee text's position to one of 9 pre-defined values
represented by a `button` on the settings dialog. Use
`int(button.objectName()[17:])` to get a number between
1-9 representing top-left to bottom-right. '''
self.parent._text_position = int(button.objectName()[17:])
def set_text_height(self, percent: int):
''' Sets marquee text's size (specifically its height) to a `percent`
between 0-100 that is relative to the current media. '''
self.parent._text_height_percent = percent / 100
def set_text_x(self, percent: float):
''' Sets marquee text's x-offset from the nearest edge to a `percent`
between 0-100 that is relative to the current media. '''
self.parent._text_x_percent = percent / 100
def set_text_y(self, percent: float):
''' Sets marquee text's y-offset to a `percent` between
0-100 that is relative to the current media. '''
self.parent._text_y_percent = percent / 100
def set_text_max_opacity(self, percent: int):
''' Sets and scales marquee text's max opacity as a
`percent` between 0-100 to a value between 0-255. '''
self.parent._text_max_opacity = round(255 * (percent / 100))
# ---
def __repr__(self) -> str:
return self.__name__
class PlayerVLC(PyPlayerBackend):
''' TODO: vlc.Instance() arguments to check out:
--align={0 (Center), 1 (Left), 2 (Right), 4 (Top), 8 (Bottom), 5 (Top-Left), 6 (Top-Right), 9 (Bottom-Left), 10 (Bottom-Right)}
--audio-time-stretch, --no-audio-time-stretch Enable time stretching audio (default enabled) <- disabled = pitch changes with playback speed
--gain=<float [0.000000 .. 8.000000]> Audio gain
--volume-step=<float [1.000000 .. 256.000000]> Audio output volume step
--marq-marquee, --sub-source=marq '''
__name__ = 'VLC'
SUPPORTS_PARSING = True
SUPPORTS_VIDEO_TRACK_MANIPULATION = True
SUPPORTS_AUDIO_TRACK_MANIPULATION = True
SUPPORTS_SUBTITLE_TRACK_MANIPULATION = True
SUPPORTS_AUTOMATIC_SUBTITLE_ENABLING = True
ENABLE_AUTOMATIC_TRACK_RESTORATION = False
ENABLE_AUTOMATIC_TRACK_RESET = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._media: vlc.Media = None
self._instance: vlc.Instance = None
self._player: vlc.MediaPlayer = None
self._event_manager = None
self.opening = False
self.ui_delay = 0.0
self.is_pitch_sensitive_audio = False
self.is_bad_with_vlc = False
self.add_to_progress_offset = 0.0
self.reset_progress_offset = False
self.swap_slider_styles_queued = False
self.text_fade_start_time = 0.0
self.text_fade_end_time = 0.0
self.text_fade_thread_open = False
self.metronome_thread_open = False
self.slider_thread_open = False
self.context_offsets = {
SetProgressContext.RESTORE: None,
SetProgressContext.RESTART: 0.0,
SetProgressContext.RESET_TO_MIN: None, # `QVideoSlider.mouseReleaseEvent()` had this as 0.01
SetProgressContext.RESET_TO_MAX: None, # `QVideoSlider.mouseReleaseEvent()` had this as 0.01
SetProgressContext.NAVIGATION_RELATIVE: 0.1,
SetProgressContext.NAVIGATION_EXACT: 0.075, # `manually_update_current_time()` had this as 0.01
SetProgressContext.SCRUB: None
}
def enable(self) -> bool:
while self.metronome_thread_open or self.slider_thread_open or self.text_fade_thread_open:
time.sleep(0.02)
super().enable()
# setup VLC instance
logger.info(f'VLC arguments: {qtstart.args.vlc}')
self._instance = vlc.Instance(qtstart.args.vlc) # VLC arguments can be passed through the --vlc argument
player = self._player = self._instance.media_player_new()
event_manager = self._event_manager = player.event_manager()
# NOTE: cannot use .emit as a callback
event_manager.event_attach(vlc.EventType.MediaPlayerEndReached, lambda event: gui.restart_signal.emit())
event_manager.event_attach(vlc.EventType.MediaPlayerOpening, lambda event: setattr(self, 'opening', True))
event_manager.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_play)
self.get_playback_rate = player.get_rate
self.set_playback_rate = player.set_rate
self.get_position = player.get_position
self.set_position = player.set_position
self.get_volume = player.audio_get_volume
self.set_volume = player.audio_set_volume
self.get_mute = player.audio_get_mute
self.set_mute = player.audio_set_mute
self.pause = player.pause
self.set_pause = player.set_pause
self.stop = player.stop
self.snapshot = lambda path, frame, w, h: player.video_take_snapshot(0, psz_filepath=path, i_width=w, i_height=h)
self.get_state = player.get_state
self.is_playing = player.is_playing
self.get_fps = player.get_fps # TODO: self.vlc.media.get_tracks() might be more accurate, but I can't get it to work
self.get_duration = lambda: player.get_length() / 1000
self.get_dimensions = player.video_get_size # ↓ VLC uses microseconds for delays for some reason
self.get_audio_delay = lambda: player.audio_get_delay() / 1000
self.set_audio_delay = lambda msec: player.audio_set_delay(msec * 1000)
self.get_subtitle_delay = lambda: player.video_get_spu_delay() / 1000
self.set_subtitle_delay = lambda msec: player.video_set_spu_delay(msec * 1000)
self.get_audio_track = player.audio_get_track
self.get_video_track = player.video_get_track
self.get_subtitle_track = player.video_get_spu
self.get_audio_tracks = lambda raw = False: self._get_tracks(player.audio_get_track_description, raw)
self.get_video_tracks = lambda raw = False: self._get_tracks(player.video_get_track_description, raw)
self.get_subtitle_tracks = lambda raw = False: self._get_tracks(player.video_get_spu_description, raw)
self.get_audio_track_count = lambda: player.audio_get_track_count() - 1
self.get_video_track_count = lambda: player.video_get_track_count() - 1
self.get_subtitle_track_count = lambda: player.video_get_spu_count() - 1
self.set_audio_track = player.audio_set_track
self.set_video_track = player.video_set_track
self.set_subtitle_track = player.video_set_spu
player.stop() # stopping the player at any point fixes the audio-cutoff bug
player.video_set_key_input(False) # pass VLC key events to widget
player.video_set_mouse_input(False) # pass VLC mouse events to widget
player.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1)
# manually refresh text-related settings
self.set_text_height(settings.spinTextHeight.value())
self.set_text_x(settings.spinTextX.value())
self.set_text_y(settings.spinTextY.value())
# start slider-related threads (these are safe to do before showing window)
self.swap_slider_styles_queued = False
self.metronome_thread_open = False
self.slider_thread_open = False
Thread(target=self.update_slider_thread, daemon=True).start()
Thread(target=self.high_precision_slider_accuracy_thread, daemon=True).start()
return True
def disable(self, wait: bool = True): # TODO do we need `gui.frame_override` in here for smooth transitions?
super().disable()
self.text_fade_thread_open = False
self.open_cleanup_queued = False
if wait:
while self.metronome_thread_open or self.slider_thread_open:
time.sleep(0.02)
self._media = None
self._instance = None
self._player = None
self._event_manager = None
def show(self):
if constants.IS_WINDOWS: self._player.set_hwnd(self.parent.winId())
elif constants.IS_MAC: self._player.set_nsobject(int(self.parent.winId()))
else: self._player.set_xwindow(self.parent.winId())
def _on_play(self, event: vlc.Event):
''' VLC event. '''
if self.opening:
self.opening = False
# HACK: for some files, VLC will always default to the wrong audio track (NO idea...
# ...why, nothing unusal in any media-parsing tool i've used and no other player...
# ...does it) -> when opening a new file, immediately set all tracks to track 1
if self.last_file != self.file_being_opened:
self.last_file = self.file_being_opened
gui.last_video_track = 1
gui.last_audio_track = 1
gui.last_subtitle_track = 1 if settings.checkAutoEnableSubtitles.isChecked() else -1
gui.last_audio_delay = 0
gui.last_subtitle_delay = 0
gui.tracks_were_changed = True # we can ignore this since the tracks are always set
gui.restore_tracks()
def on_parse(self, file: str, base_mime: str, mime: str, extension: str):
if base_mime == 'image':
gui._open_cleanup_signal.emit() # manually emit _open_cleanup_signal for images/gifs (slider thread will be idle)
self.is_pitch_sensitive_audio = False
self.is_bad_with_vlc = False
else:
self.open_cleanup_queued = True # `open_cleanup_queued` + `open_in_progress` and `frame_override` work...
gui.frame_override = 0 # ...together to halt `update_slider_thread()` and trigger cleanup safely
# TODO: we should really be tracking the codec instead of the container here
# TODO: can this be fixed with a different demuxer or something? (what we COULD have done to fix pitch-shifting)
if extension == 'ogg': # TODO: flesh out a list of unresponsive media types
self.is_bad_with_vlc = True
self.is_pitch_sensitive_audio = False
else:
self.is_bad_with_vlc = False
self.is_pitch_sensitive_audio = mime == 'audio'
# update marquee size and offset relative to video's dimensions
if mime == 'video':
height = gui.vheight
parent = self.parent
set_marquee_int = self._player.video_set_marquee_int
set_marquee_int(vlc.VideoMarqueeOption.Size, int(height * parent._text_height_percent))
set_marquee_int(vlc.VideoMarqueeOption.X, int(height * parent._text_x_percent))
set_marquee_int(vlc.VideoMarqueeOption.Y, int(height * parent._text_y_percent))
def on_open_cleanup(self):
# warn users that the current media will not scrub/navigate very well
# TODO: what else needs to be here (and set as not `self.is_pitch_sensitive_audio`)?
if self.is_bad_with_vlc:
gui.statusbar.showMessage(f'Note: Files of this mime type/encoding ({gui.mime_type}/{gui.extension}) may be laggy or unresponsive while scrubbing/navigating on some systems (libVLC issue).')
def on_restart(self):
self.play(gui.video)
frame = gui.frame_count
self.set_position((frame - 2) / frame) # reset position (-2 frames to ensure visual update for VLC)
gui.update_progress_signal.emit(frame) # ensure UI snaps to final frame
while self.get_state() == State.Ended: # wait for VLC to update the player's state
time.sleep(0.005)
def play(self, file: str, will_restore: bool = False, _error: bool = False) -> bool:
''' Open, parse, and play a `file` in libVLC, returning True if
successful. If `file` cannot be played, the currently opened file
is reopened if possible. NOTE: Parsing is started asynchronously.
This function returns immediately upon playing the file. '''
try:
self._media = self._instance.media_new(file) # combines media_new_path (local files) and media_new_location (urls)
self._player.set_media(self._media) # TODO: this line has a HUGE delay when opening first file after opening extremely large video
#self._player.set_mrl(self.media.get_mrl()) # not needed? https://www.olivieraubert.net/vlc/python-ctypes/doc/vlc.MediaPlayer-class.html#set_mrl
self._player.play()
# NOTE: parsing normally is still relatively fast, but libVLC is not as effective/compatible as FFprobe
# additionally, reading an already-created probe file is MUCH faster (relatively) than parsing with libVLC
self._media.parse_with_options(0x0, 0) # https://www.olivieraubert.net/vlc/python-ctypes/doc/vlc.Media-class.html#parse_with_options
self.file_being_opened = file
return True
except:
logger.warning(f'VLC failed to play file {file}: {format_exc()}')
if not _error and file != gui.video: # `_error` ensures we only attempt to play previous video once
if not gui.video: self._player.stop() # no previous video to play, so just stop playing
else: self.play(gui.video, _error=True) # attempt to play previous working video
return False
def loop(self):
self.play(gui.video)
# TODO just in case doing `set_and_update_progress` causes hitches or delays, we're...
# ...doing an if-statement instead to ensure normal loops are slightly more seamless
#set_and_update_progress(self.minimum) # <- DOES this cause hitches?
if gui.buttonTrimStart.isChecked():
return gui.update_progress(0)
return self.set_and_update_progress(gui.minimum, SetProgressContext.RESET_TO_MIN)
def can_restart(self) -> bool:
# HACK: sometimes VLC will double-restart -> replay/restore position ASAP
if gui.restarted:
logging.info('Double-restart detected. Ignoring...')
gui.restarted = False # set this so we don't get trapped in an infinite restart-loop
return gui.restore(gui.sliderProgress.value())
# reset frame_override in case it's set
gui.frame_override = -1
# HACK: skip this restart if needed and restore actual progress
if gui.ignore_imminent_restart:
gui.ignore_imminent_restart = False
gui.restarted = True
return gui.restore(gui.sliderProgress.value())
# we're good to go. continue with restart
return True
def is_parsed(self) -> bool:
_player = self._player
return (
self._media.get_parsed_status() == 4
and _player.get_fps() != 0
and _player.get_length() != 0
and _player.video_get_size() != (0, 0)
)
def set_playback_rate(self, rate: float):
self._player.set_playback_rate(rate)
if rate == 1.0 or gui.playback_rate == 1.0: # TODO: for now, lets just force the VLC-progress for non-standard speeds
self.reset_progress_offset = True
self.swap_slider_styles_queued = True
def set_and_update_progress(self, frame: int = 0, context: int = SetProgressContext.NONE):
''' Simultaneously sets VLC/gif player position to `frame`, avoids the
pitch-shift-bug for unpaused audio, and adjusts the high-precision
progress offset by `offset` seconds (if provided) to account for
VLC buffering. `offset` is ignored if `self.is_paused` is True. '''
# don't touch progress if we're currently opening a file
if gui.open_in_progress:
return
offset = self.context_offsets.get(context, None)
if offset is None:
return super().set_and_update_progress(frame, context)
is_paused = gui.is_paused
is_pitch_sensitive_audio = self.is_pitch_sensitive_audio
# HACK: "replay" audio file to correct VLC's pitch-shifting bug
# https://reddit.com/r/VLC/comments/i4m0by/pitch_changing_on_seek_only_some_audio_file_types/
# https://reddit.com/r/VLC/comments/b0i9ff/music_seems_to_pitch_shift_all_over_the_place/
if is_pitch_sensitive_audio and not is_paused:
self._player.set_media(self._media)
self._player.play()
#self.set_player_time(round(frame * (1000 / gui.frame_rate)))
self.set_position(frame / gui.frame_count)
gui.update_progress(frame) # necessary while paused and for a snappier visual update
gui.gifPlayer.gif.jumpToFrame(frame)
# NOTE: setting `frame_override` here on videos can cause high-precision progress...
# ...to desync by a few frames, but prevents extremely rare timing issues that...
# ...stop the slider from updating to its new position. is this trade-off worth it?
# NOTE: `frame_override` sets `add_to_progress_offset` to 0.1 if it's 0
# -> add 0.001 to `offset` to ensure it doesn't get ignored
if settings.checkHighPrecisionProgress.isChecked() and not is_pitch_sensitive_audio:
self.add_to_progress_offset = -0.075 if is_paused else offset + 0.001
gui.frame_override = frame # ^ set offset BEHIND current time while paused. i don't understand why, but it helps
def _get_tracks(self, get_description, raw: bool = False) -> tuple[int, str]:
if raw:
for id, title in get_description():
yield id, title.decode()
# VLC may add tags to track titles, like "Track 1 - [English]" -> try to detect and remove these
else:
for id, title in get_description():
fake_tags = []
parts = title.decode().split(' - ')
title = parts[0]
for tag in reversed(parts[1:]):
if fake_tags: # if we found a non-tag, don't look for tags before it in...
fake_tags.append(tag) # ...the title, e.g. "Track 1 - [Don't Detect Me] - Yippee"
continue
tag = tag.strip()
if tag[0] != '[' or tag[-1] != ']':
fake_tags.append(tag)
if fake_tags: # reapply all valid nontags
title = f'{title} - {" - ".join(reversed(fake_tags))}'
yield id, title
def add_audio_track(self, url: str, enable: bool = False) -> bool:
if self._player.add_slave(1, url, enable) == 0: # slaves can be subtitles (0) or audio (1)
gui.log_on_statusbar_signal.emit(f'Audio file {url} added and enabled.')
if settings.checkTextOnSubtitleAdded.isChecked():
self.show_text('Audio file added and enabled')
return True
else: # returns 0 on success
gui.log_on_statusbar_signal.emit(f'Failed to add audio file {url} (VLC does not report specific errors for this).')
if settings.checkTextOnSubtitleAdded.isChecked():
self.show_text('Failed to add audio file')
def add_subtitle_track(self, url: str, enable: bool = False) -> bool:
if self._player.add_slave(0, url, enable) == 0: # slaves can be subtitles (0) or audio (1)
gui.log_on_statusbar_signal.emit(f'Subtitle file {url} added and enabled.')
if settings.checkTextOnSubtitleAdded.isChecked():
self.show_text('Subtitle file added and enabled')
return True
else: # returns 0 on success
gui.log_on_statusbar_signal.emit(f'Failed to add subtitle file {url} (VLC does not report specific errors for this).')
if settings.checkTextOnSubtitleAdded.isChecked():
self.show_text('Failed to add subtitle file')
def set_text_position(self, button: QtW.QRadioButton):
self.parent._text_position = ( # libVLC uses wacky position values, so map them accordingly
5, 4, 6,
1, 0, 2,
9, 8, 10
)[int(button.objectName()[17:]) - 1]
self._player.video_set_marquee_int(vlc.VideoMarqueeOption.Position, self.parent._text_position)
def set_text_height(self, percent: int):
self.parent._text_height_percent = percent / 100
new_size = int(gui.vheight * self.parent._text_height_percent)
self._player.video_set_marquee_int(vlc.VideoMarqueeOption.Size, new_size)
def set_text_x(self, percent: float):
self.parent._text_x_percent = percent / 100 # ↓ offset is relative to media's height for both X and Y
new_x = int(gui.vheight * self.parent._text_x_percent)
self._player.video_set_marquee_int(vlc.VideoMarqueeOption.X, new_x)
def set_text_y(self, percent: float):
self.parent._text_y_percent = percent / 100 # ↓ offset is relative to media's height for both X and Y
new_y = int(gui.vheight * self.parent._text_y_percent)
self._player.video_set_marquee_int(vlc.VideoMarqueeOption.Y, new_y)
def show_text(self, text: str, timeout: int = 350, position: int = None):
''' Displays marquee `text` on the player, for at least `timeout` ms at
`position`: 0 (Center), 1 (Left), 2 (Right), 4 (Top), 5 (Top-Left),
6 (Top-Right), 8 (Bottom), 9 (Bottom-Left), 10 (Bottom-Right).
NOTE: If `timeout` is 0, `text` will stay visible indefinitely.
TODO: marquees are supposed to be chainable -> https://wiki.videolan.org/Documentation:Modules/marq/
NOTE: vlc.py claims "Marquee requires '--sub-source marq' in the Instance() call" <- not true?
NOTE: VLC supports %-strings: https://wiki.videolan.org/Documentation:Format_String/
Escape isolated % characters with %%. Use VideoMarqueeOption.Refresh to auto-update text on
an interval. See the bottom of vlc.py for an example implementation of an on-screen clock. '''
if not settings.groupText.isChecked(): return # marquees are completely disabled -> return
try:
# calculate when the text should start and complete its fading animation
delay = max(timeout / 1000, settings.spinTextFadeDelay.value())
self.text_fade_start_time = time.time() + delay
if timeout == 0: # `timeout` of 0 -> leave the text up indefinitely
self.text_fade_end_time = self.text_fade_start_time
else:
fade_duration = settings.spinTextFadeDuration.value()
if fade_duration < 0.1: # any lower than 0.1 seconds -> disappear instantly
fade_duration = -0.1
self.text_fade_end_time = self.text_fade_start_time + fade_duration
# reset opacity to default (repetitive but sometimes necessary)
self._player.video_set_marquee_int(vlc.VideoMarqueeOption.Opacity, self.parent._text_max_opacity)
# see if we actually need to update the text/position
if position is None: # reuse last position if needed
position = self.parent._text_position
new_settings = (text, timeout, position)
unique_settings = new_settings != self.last_text_settings
# actually set text and position if they're unique
if (timeout == 0 and not unique_settings) or not gui.video:
return # avoid repetitive + pointless calls
if unique_settings:
self._player.video_set_marquee_int(vlc.VideoMarqueeOption.Position, position)
self._player.video_set_marquee_string(vlc.VideoMarqueeOption.Text, text)
self.last_text_settings = new_settings
# start fading thread if it hasn't been started already
if not self.text_fade_thread_open:
Thread(target=self.text_fade_thread, daemon=True).start()
self.text_fade_thread_open = True
except:
logger.warning(f'(!) Unexpected error while showing text overlay: {format_exc()}')
def text_fade_thread(self):
''' A thread for animating libVLC's `VideoMarqueeOption.Opacity`
property. TODO: Should this be a `QTimer` instead? '''
_player = self._player
while self.enabled:
now = time.time()
if now >= self.text_fade_start_time:
end = self.text_fade_end_time
fade_duration = end - self.text_fade_start_time
if fade_duration == 0: # don't fade at all, leave text up until told otherwise
time.sleep(0.1)
elif now <= end:
alpha = ((end - now) / fade_duration) * self.parent._text_max_opacity
_player.video_set_marquee_int(vlc.VideoMarqueeOption.Opacity, round(alpha))
time.sleep(0.025) # fade out at 40fps
else: # if we just finished fading, make sure no text is visible
#_player.video_set_marquee_string(vlc.VideoMarqueeOption.Text, '')
_player.video_set_marquee_int(vlc.VideoMarqueeOption.Opacity, 0)
self.text_fade_start_time = 9999999999 # set start_time to extreme number to stop the loop
else: # sleep as long as possible (but < the shortest possible delay)
time.sleep(0.25 if self.text_fade_start_time - now > 0.5 else 0.01)
self.text_fade_thread_open = False
return logging.info('VLC player disabled. Ending text_fade thread.')
def high_precision_slider_accuracy_thread(self):
''' A thread for monitoring the accuracy of `self.update_slider_thread`
compared to the real-world time it's been active. Once per second,
the current UI frame is compared to how many actual seconds it's
been since play was last started/resumed as well as the frame it
started from to see how far we've deviated from reality. The inter-
frame delay (`self.delay`) is then adjusted using `self.ui_delay`
speed up or slow down the UI so that it lines up exactly right, one
second from now.
If the UI desyncs by more than one second from actual time or more
than two seconds from libVLC's native progress, the UI is reset.
Accuracy loop loops indefinitely until `self.reset_progress_offset`
is set to True, then it breaks from the loop and resets its values.
HACK: `self.add_to_progress_offset` is a float added to the initial
starting time in order to account for microbuffering within libVLC
(which is NOT reported or detectable anywhere, seemingly). A better
solution is needed, but I'm not sure one exists. Even libVLC's media
stats (read_bytes, displayed_pictures, etc.) are updated at the same
awful, inconsistent rate that its native progress is updated, making
them essentially useless. At the very least, most mid-high range
systems "buffer" at the same speed (~0.05-0.1 seconds, is that also
partially tied to libVLC's update system?). Only low-end systems
will fall slightly behind (but (probably) never desync). '''
play_started = 0.0
frame_started = 0
current_frame = 0
vlc_frame = 0.0
seconds_elapsed = 0.0
frames_elapsed = 0.0
frame_desync = 0.0
time_desync = 0.0
vlc_desync = 0.0
# re-define global aliases -> having them as locals is even faster
_gui = gui
get_ui_frame = _gui.sliderProgress.value
is_playing = self.is_playing
_sleep = time.sleep
_get_time = time.time
check_interval = 1
intercheck_count = 20
delay_per_intercheck = check_interval / intercheck_count
vlc_desync_counter_limit = 2 # how many times in a row VLC must be desynced before we care
while self.enabled:
# stay relatively idle while minimized, nothing is active, or we're waiting for something
while not _gui.isVisible() and self.enabled: _sleep(0.25)
while _gui.isVisible() and not is_playing() and self.enabled: _sleep(0.02)
while _gui.open_in_progress or _gui.frame_override != -1: _sleep(0.01)
start = _get_time()
play_started = start + self.add_to_progress_offset
frame_started = get_ui_frame()
self.reset_progress_offset = False
self.add_to_progress_offset = 0.0
vlc_desync_limit = _gui.frame_rate * 2
vlc_desync_counter = 0
while is_playing() and not self.reset_progress_offset and not _gui.open_in_progress:
seconds_elapsed = (_get_time() - play_started) * _gui.playback_rate
frames_elapsed = seconds_elapsed * _gui.frame_rate
current_frame = get_ui_frame()
vlc_frame = self.get_position() * _gui.frame_count
frame_desync = current_frame - frames_elapsed - frame_started
time_desync = frame_desync / _gui.frame_rate
absolute_time_desync = abs(time_desync)
vlc_desync = current_frame - vlc_frame
# if we're greater than 1 second off our expected time or 2 seconds off VLC's time...
# ...something is wrong -> reset to just past VLC's frame (VLC is usually a bit behind)
# NOTE: VLC can be deceptive - only listen to VLC if it's been desynced for a while
vlc_is_desynced = vlc_frame > 0 and abs(vlc_desync) > vlc_desync_limit
if vlc_is_desynced: vlc_desync_counter += 1
else: vlc_desync_counter = 0
if absolute_time_desync >= 1 or vlc_desync_counter >= vlc_desync_counter_limit:
self.ui_delay = _gui.delay
true_frame = (self.get_position() * _gui.frame_count) + (_gui.frame_rate * 0.2)
logging.info(f'(?) High-precision progress desync: {time_desync:.2f} real seconds, {vlc_desync:.2f} VLC frames. Changing frame from {current_frame} to {true_frame}.')
# double-check our conditions in case of extremely unlucky timing
if not is_playing() or self.reset_progress_offset or _gui.open_in_progress:
break
# if frame_override is already set, it will be resetting for us anyways
# don't break - just let things run their course
if _gui.frame_override == -1:
_gui.frame_override = int(true_frame)
# otherwise, adjust delay accordingly to stay on track
else:
if time_desync >= 0: self.ui_delay = _gui.delay * (1 + absolute_time_desync) # we're ahead (need to slow down)
else: self.ui_delay = _gui.delay / (1 + absolute_time_desync) # we're behind (need to speed up)
# TODO: have setting or debug command line argument that actually logs these every second?
#logging.debug(f'VLC\'s frame: {vlc_frame:.1f}, Our frame: {current_frame} (difference of {vlc_desync:.1f} frames, or {vlc_desync / _gui.frame_rate:.2f} seconds)')
#logging.debug(f'New delay: {self.ui_delay} (delta_frames={delta_frames:.1f}, delta_seconds={delta_seconds:2f})')
# wait for next check, but account for the time it took to actually run through the loop
time_elapsed = 0.0
while time_elapsed < check_interval:
if not is_playing() or self.reset_progress_offset or _gui.open_in_progress:
break
_sleep(delay_per_intercheck)
time_elapsed = _get_time() - start
start = _get_time()
# all loops broken, player backend disabled
self.metronome_thread_open = False
return logging.info('VLC player disabled. Ending high_precision_slider_accuracy thread.')
def update_slider_thread(self):
''' Handles updating the progress bar. This includes both slider-types
and swapping between them. Set `_gui.frame_override` to override the
next pending frame (preventing timing-related bugs). If set while
`_gui.open_in_progress` is True, this thread halts before signalling
`_gui._open_cleanup_slot()` once `self.open_cleanup_queued` is True,
then halts again until the opening process is fully complete. While
not playing, the slider is manually updated at 20fps to keep
animations working smoothly without draining resources.
While minimized, resource-usage is kept to a minimum. '''
logging.info('Slider-updating thread started.')
# re-define global aliases -> having them as locals is even faster
_gui = gui
get_ui_frame = _gui.sliderProgress.value
repaint_slider = _gui.sliderProgress.update
is_playing = self.is_playing
is_high_precision = _gui.dialog_settings.checkHighPrecisionProgress.isChecked
emit_open_cleanup_signal = _gui._open_cleanup_signal.emit
_emit_update_progress_signal = _gui.update_progress_signal.emit
_sleep = time.sleep
_get_time = time.time
# set the minimum fps the slider MUST update at to ensure...
# ...animations tied to the slider continue to work (smoothly)
# NOTE: this number must match the `fps` variable that...
# ...appears twice in `QVideoSlider.paintEvent()`
min_fps = 20 # TODO this is applied even for non-fullscreen images
min_fps_delay = 1 / min_fps
while self.enabled:
# window is NOT visible, stay relatively idle and do not update
while not _gui.isVisible() and self.enabled:
_sleep(0.25)
# window is visible, but nothing is actively playing (NOTE: `is_playing()` will be False for images)
while _gui.isVisible() and not is_playing() and self.enabled:
repaint_slider() # force `QVideoSlider` to keep painting
_sleep(min_fps_delay) # update at `min_fps`
# reset queued slider-swap (or the slider won't update anymore after a swap)
self.swap_slider_styles_queued = False