results.py 18.5 KB
Newer Older
1
# results.py
Todd Weaver's avatar
Todd Weaver committed
2
#
3
# Copyright 2021 Purism, SPC
Todd Weaver's avatar
Todd Weaver committed
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import gi
gi.require_version('Gtk', '3.0')
20
21
22
gi.require_version('Gdk', '3.0')
gi.require_version('Gio', '2.0')
gi.require_version('Gst', '1.0')
Todd Weaver's avatar
Todd Weaver committed
23
gi.require_version('Handy', '1')
24
25
26
27
gi.require_version('Soup', '2.4')
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gst, Gtk, Handy, Soup

import json
28

29
30
Gst.init(None)
Gst.init_check(None)
Todd Weaver's avatar
Todd Weaver committed
31
32
Handy.init()

33
34
import time

Todd Weaver's avatar
Todd Weaver committed
35
36
37
38
@Gtk.Template(resource_path='/sm/puri/Stream/ui/results.ui')
class ResultsBox(Gtk.Box):
    __gtype_name__ = 'ResultsBox'

39
    event_box = Gtk.Template.Child()
40
41
    player_box = Gtk.Template.Child()
    poster_image = Gtk.Template.Child()
42
    controls_box = Gtk.Template.Child()
43
44
    play = Gtk.Template.Child()
    pause = Gtk.Template.Child()
45
    slider = Gtk.Template.Child()
46
47
    time_viewed = Gtk.Template.Child()
    time_remaining = Gtk.Template.Child()
48

49
    audio_dl = Gtk.Template.Child()
50
    audio_dl_image = Gtk.Template.Child()
51
    video_dl = Gtk.Template.Child()
52
    video_dl_image = Gtk.Template.Child()
53
54
55
56
57
    speed = Gtk.Template.Child()
    fullscreen = Gtk.Template.Child()
    unfullscreen = Gtk.Template.Child()

    details = Gtk.Template.Child()
58
59
60
    title = Gtk.Template.Child()
    channel = Gtk.Template.Child()
    duration = Gtk.Template.Child()
61

62
63
    window_to_player_box_padding = 28

64
    def __init__(self, app_window, priority_index, **kwargs):
Todd Weaver's avatar
Todd Weaver committed
65
66
        super().__init__(**kwargs)

67
        self.app_window = app_window
68
69
70
        self.priority = 0
        if priority_index > 0:
            self.priority = GLib.PRIORITY_LOW
71
72
73
74
75
76
77
78
79
80
81
82

        # listen for motion on the player box for controls show/hide
        self.event_box.add_events(Gdk.EventMask.POINTER_MOTION_MASK)

        # determine window width at time of search
        # do ratio calculation from width (16:9 or 1.77)
        # retain aspect ratio
        size = self.app_window.get_size()
        self.app_orig_width = size.width
        self.app_orig_height = size.height
        self.video_box_width = int(size.width - self.window_to_player_box_padding)
        self.video_box_height = int(self.video_box_width / 1.77)
83

84
85
86
        self.player_box.set_size_request(self.video_box_width, self.video_box_height)
        self.poster_image.set_size_request(self.video_box_width, self.video_box_height)

87
88
89
90
        # init gstreamer player
        self.player = Gst.ElementFactory.make("playbin", "player")
        self.sink = Gst.ElementFactory.make("gtksink")

91
92
        self.video_widget = self.sink.get_property("widget")
        self.video_widget.set_size_request(self.video_box_width, self.video_box_height)
93

94
        self.player_box.add(self.video_widget)
95

96
    def get_readable_seconds(self, seconds):
97
98
99
        m, s = divmod(seconds, 60)
        h, m = divmod(m, 60)
        if seconds >= 3600:
100
            readable_seconds = f"{h:d}:{m:02d}:{s:02d}"
101
        else:
102
103
            readable_seconds = f"{m:d}:{s:02d}"
        return readable_seconds
104

105
    def on_file_read(self, poster_file, async_res, user_data):
106
107
108
109
110
        try:
            stream = poster_file.read_finish(async_res)
        except GLib.Error as e:
            return False

111
        GdkPixbuf.Pixbuf.new_from_stream_at_scale_async(stream,
112
113
114
115
116
                self.video_box_width, self.video_box_height,
                True,                # preserve_aspect_ratio
                None,                # cancellable
                self.on_stream_load, # callback
                None)                # user_data
117
118
119
120
121
122
123
124

    def on_stream_load(self, source, async_res, context):
        pixbuf = GdkPixbuf.Pixbuf.new_from_stream_finish(async_res)

        self.poster_image.clear()
        self.poster_image.set_from_pixbuf(pixbuf)

    def stream_at_scale_async(self, poster_file):
125
        stream = poster_file.read_async(self.priority, None,
126
                self.on_file_read, None)
127

128
129
130
131
132
133
134
135
136
137
    def check_video_playable(self, video_url):
        session = Soup.Session.new()
        session.set_property("timeout", 2)
        message = Soup.Message.new("HEAD", video_url)
        session.queue_message(message, self.check_video_playable_cb, None)

    def check_video_playable_cb(self, session, results, user_data):
        if results.status_code != 200:
            self.set_visible(False)

138
139
    def parse_video_results(self, session, result, message):
        if message.status_code != 200:
140
141
            # remove unplayable video urls from list
            self.set_visible(False)
142
            return False
143

144
145
146
        try:
            self.json = json.loads(message.response_body.data)
        except:
147
            self.set_visible(False)
148
149
150
151
            return False

        self.video_uri = None
        for format_stream in self.json['formatStreams']:
152

153
154
            if format_stream['qualityLabel'] == "360p":
                self.video_uri = format_stream['url']
155

156
157
158
159
160
161
162
163
164
            # if (future) user-config desires 720p,
            # check if it is available and if so use it instead
            #if format_stream['qualityLabel'] == "720p":
            #    self.video_uri = format_stream['url']

        if not self.video_uri:
            self.set_visible(False)
            return False

165
166
        self.check_video_playable(self.video_uri)

167
168
169
        self.get_download_uris()

        self.player.set_property("uri", self.video_uri)
170
171
        self.player.set_property("video-sink", self.sink)

172
        poster_file = Gio.File.new_for_uri(self.poster_uri)
173

174
        self.stream_at_scale_async(poster_file)
175

176
177
178
179
180
181
182
183
184
185
186
187
188
    def get_video_details(self):
        uri = f"{self.instance}/api/v1/videos/{self.video_id}?fields=adaptiveFormats,formatStreams"
        self.session = Soup.Session.new()
        self.session.set_property("timeout", 5)
        message = Soup.Message.new("GET", uri)
        self.session.queue_message(message, self.parse_video_results, message)

    def setup_stream(self, video_meta):
        self.instance = video_meta['strong_instance']
        self.video_id = video_meta['videoId']
        self.video_title = video_meta['title']
        self.video_channel = video_meta['author']
        self.poster_uri = video_meta['poster_uri']
189
        video_duration = self.get_readable_seconds(video_meta['lengthSeconds'])
190
191
192

        self.title.set_label(self.video_title)
        self.channel.set_label(self.video_channel)
193
        self.duration.set_label(video_duration)
194
195
196

        self.get_video_details()

197
    def update_slider(self):
198
        if not self.app_window.is_playing:
199
200
            return False
        else:
201
            success, duration = self.player.query_duration(Gst.Format.TIME)
202
203

            # GtkScale is set to 100%, calculate duration and position steps
204
            self.percent = 100 / (duration / Gst.SECOND)
205
206
207
208
209
210

            # get current position (nanoseconds)
            success, position = self.player.query_position(Gst.Format.TIME)

            position_value = float(position) / Gst.SECOND * self.percent

211
212
213
214
215
216
217
218
219
            if int(duration) > 0:
                viewed_seconds = int(position / Gst.SECOND)
                remaining_seconds = int((duration - position) / Gst.SECOND)
                viewed = self.get_readable_seconds(viewed_seconds)
                remaining = self.get_readable_seconds(remaining_seconds)

                self.time_viewed.set_label(viewed)
                self.time_remaining.set_label(f"-{remaining}")

220
221
222
                if int(position / Gst.SECOND) >= int(duration / Gst.SECOND):
                    self.null_out_player()

223
224
225
226
            # is negative number when not successful, so put it to 0
            if not success:
                position_value = 0
            
227
228
229
230
231
232
233
234
            try:
                # block seek slider function so it doesn't loop itself
                self.slider.handler_block_by_func(self.seek_slider)
                self.slider.set_value(position_value)
                self.slider.handler_unblock_by_func(self.seek_slider)
            except:
                return False

235
236
        return True

237
238
239
240
241
242
243
244
245
    def strictify_name(self, s):
        return "".join( x for x in s if (x.isalnum() or x in "_- "))

    def download_audio_uri(self, uri):
        dl_stream = Gio.File.new_for_uri(uri)
        dest_ext = "m4a"
        try:
            dest_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC)
        except:
246
            self.show_error_icon('audio')
247
248
249
250
251
252
            return False

        dest_title = self.strictify_name(self.video_title)
        dest_path = f"{dest_dir}/{dest_title}.{dest_ext}"
        dest = Gio.File.new_for_path(dest_path)
        dl_stream.copy_async(dest, Gio.FileCopyFlags.OVERWRITE,
253
                GLib.PRIORITY_LOW, None,
254
255
256
257
258
259
260
261
262
                self.progress_audio_cb, (),
                self.ready_audio_cb, None)

    def download_video_uri(self, uri):
        dl_stream = Gio.File.new_for_uri(uri)
        dest_ext = "mp4"
        try:
            dest_dir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_VIDEOS)
        except:
263
            self.show_error_icon('video')
264
265
266
267
268
            return False

        dest_title = self.strictify_name(self.video_title)
        dest_path = f"{dest_dir}/{dest_title}.{dest_ext}"
        dest = Gio.File.new_for_path(dest_path)
269
270
271
        flags = Gio.FileCopyFlags
        dl_stream.copy_async(dest,
                # bitwise or (not tuple) for multiple flags
272
273
274
                Gio.FileCopyFlags.OVERWRITE |
                Gio.FileCopyFlags.ALL_METADATA |
                Gio.FileCopyFlags.TARGET_DEFAULT_PERMS,
275
                GLib.PRIORITY_LOW, None,
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
                self.progress_video_cb, (),
                self.ready_video_cb, None)

    def show_success_icon(self, button):
        if button == 'audio':
            self.audio_dl_image.set_property('icon-name', 'object-select-symbolic')
        elif button == 'video':
            self.video_dl_image.set_property('icon-name', 'object-select-symbolic')

    def show_progress_icon(self, button):
        # could show progress in icon
        if button == 'audio':
            self.audio_dl_image.set_property('icon-name', 'document-save-as-symbolic')
        elif button == 'video':
            self.video_dl_image.set_property('icon-name', 'document-save-as-symbolic')

    def show_error_icon(self, button):
        if button == 'audio':
            self.audio_dl_image.set_property('icon-name', 'dialog-error-symbolic')
        elif button == 'video':
            self.video_dl_image.set_property('icon-name', 'dialog-error-symbolic')

    def progress_audio_cb(self, current_num_bytes, total_num_bytes, *user_data):
        percentage = round(current_num_bytes / total_num_bytes * 100)
        #print(f"Audio Downloading: {percentage}%", end="\r")

    def progress_video_cb(self, current_num_bytes, total_num_bytes, *user_data):
        percentage = round(current_num_bytes / total_num_bytes * 100)

    def ready_audio_cb(self, src, async_res, user_data):
        try:
            src.copy_finish(async_res)
        except GLib.Error as e:
            self.show_error_icon('audio')
            return False
        self.show_success_icon('audio')

    def ready_video_cb(self, src, async_res, user_data):
        try:
            src.copy_finish(async_res)
        except GLib.Error as e:
            self.show_error_icon('video')
            return False
        self.show_success_icon('video')

321
322
    @Gtk.Template.Callback()
    def audio_dl_button(self, button):
323
324
325
        self.audio_dl.set_sensitive(False)
        self.show_progress_icon('audio')
        self.download_audio_uri(self.audio_dl_uri)
326
327
328

    @Gtk.Template.Callback()
    def video_dl_button(self, button):
329
330
331
        self.video_dl.set_sensitive(False)
        self.show_progress_icon('video')
        self.download_video_uri(self.video_dl_uri)
332
333
334

    @Gtk.Template.Callback()
    def play_button(self, button):
335
        # loop through all child results pausing them
336
        self.app_window.pause_all(self)
337

338
339
        self.play.set_visible(False)
        self.pause.set_visible(True)
340
        self.app_window.is_playing = True
341
342
        self.app_window.inhibit_app()
        self.player.set_state(Gst.State.PLAYING)
343
344
345
346
347
348
349
350
351
352
353

        # hide the poster, show the video
        self.player_box.show_all()
        self.poster_image.hide()

        # initialize the slider
        self.update_slider()
        # allow seeking
        self.slider.set_sensitive(True)

        # update slider to track video time in slider
354
        GLib.timeout_add_seconds(1, self.update_slider)
355

356
    def null_out_player(self):
357
        self.inactivate_player()
358
359
        self.player.set_state(Gst.State.NULL)

360
361
    @Gtk.Template.Callback()
    def pause_button(self, button):
362
363
364
365
        self.inactivate_player()
        self.player.set_state(Gst.State.PAUSED)

    def inactivate_player(self):
366
367
        self.play.set_visible(True)
        self.pause.set_visible(False)
368
        self.app_window.is_playing = False
369
        self.app_window.uninhibit_app()
370
371
372
373
374

    @Gtk.Template.Callback()
    def speed_button(self, button):
        print("speed_button")

375
376
377
378
    def resize_player(self, width, height):
        self.poster_image.set_size_request(width, height)
        self.video_widget.set_size_request(width, height)

Todd Weaver's avatar
Todd Weaver committed
379
380
381
    def delay_grab(self):
        self.grab_focus()

382
383
    @Gtk.Template.Callback()
    def fullscreen_button(self, button):
384
385
386
        self.fullscreen.set_visible(False)
        self.unfullscreen.set_visible(True)
        self.app_window.fullscreen()
387
        self.app_window.is_fullscreen = True
388
389
390
391
392
393
394
395
396
397
398
399
400
        self.app_window.search_bar_toggle.set_active(False)
        self.app_window.search_bar.set_visible(False)
        self.app_window.header_bar.set_visible(False)
        self.details.set_visible(False)

        set_width = int(Gdk.Screen.get_default().get_width())
        set_height = int(Gdk.Screen.get_default().get_height())
        self.resize_player(set_width, set_height)

        results_context = self.get_style_context()
        results_context.remove_class("results")
        results_context.add_class("fullscreen")

Todd Weaver's avatar
Todd Weaver committed
401
402
403
404
        # grabbing happens before resize completes
        # adding a slight delay to grab focus after resize completes
        GLib.timeout_add(50, self.delay_grab)

405
        # horizonal scrollbar, vertical scrollbar (do last)
406
407
        scroller = self.app_window.scroller_stack.get_visible_child()
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.EXTERNAL)
Todd Weaver's avatar
Todd Weaver committed
408
        scroller.set_kinetic_scrolling(False)
409

410
411
412
413
414
    @Gtk.Template.Callback()
    def unfullscreen_button(self, button):
        self.fullscreen.set_visible(True)
        self.unfullscreen.set_visible(False)
        self.app_window.unfullscreen()
415
        self.app_window.is_fullscreen = False
416
417
418
419
        self.app_window.search_bar_toggle.set_active(True)
        self.app_window.search_bar.set_visible(True)
        self.app_window.header_bar.set_visible(True)
        self.details.set_visible(True)
420

421
422
423
424
425
426
427
428
429
        results_context = self.get_style_context()
        results_context.remove_class("fullscreen")
        results_context.add_class("results")

        set_width = int(self.app_orig_width - self.window_to_player_box_padding)
        set_height = int(set_width / 1.77)

        self.resize_player(set_width, set_height)
        self.app_window.resize(self.app_orig_width, self.app_orig_height)
430
431
432

        scroller = self.app_window.scroller_stack.get_visible_child()
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
Todd Weaver's avatar
Todd Weaver committed
433
        scroller.set_kinetic_scrolling(True)
434

435
436
        self.grab_focus()

437
438
439
440
441
442
443
444
    @Gtk.Template.Callback()
    def seek_slider(self, scale):
        seek = scale.get_value()

        # allow seeking when playing
        self.player.seek_simple(Gst.Format.TIME,
            Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
            seek * Gst.SECOND / self.percent)
445
446

    def poll_mouse(self):
447
448
449
450
        now_is = int(GLib.get_current_time())
        if (int(now_is) - (self.last_move)) >= 1:
            if self.controls_box.get_visible():
                self.controls_box.set_visible(False)
451
452

    @Gtk.Template.Callback()
453
    def event_box_mouse_click(self, event, data):
454
        if self.app_window.is_playing:
455
456
457
458
459
            self.pause_button(None)
        self.event_box_mouse_action(event, data)

    @Gtk.Template.Callback()
    def event_box_mouse_action(self, event, data):
460
461
        self.last_move = int(GLib.get_current_time())
        GLib.timeout_add_seconds(2, self.poll_mouse)
462
463
        if not self.controls_box.get_visible():
            self.controls_box.set_visible(True)
464
465
466
467

    @Gtk.Template.Callback()
    def swallow_slider_scroll_event(self, event, data):
        return True
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

    def get_download_uris(self):
        # get download link urls based on (future) user-config
        # video quality: ["480p", "720p", "1080p"] # default 720p
        # audio structure:
        #     "bitrate": "142028",
        #     "type": "audio/webm; codecs=\"opus\"",
        #     "container": "webm",
        # video structure:
        #     "bitrate": "440700",
        #     "type": "video/mp4; codecs=\"avc1.4d401f\"",
        #     "container": "mp4",
        #     "qualityLabel": "720p"

        last_bitrate = None
        self.audio_dl_uri = None
        for af in self.json['adaptiveFormats']:
            if af['type'].startswith('audio/mp4'):
                if not self.audio_dl_uri:
                    last_bitrate = af['bitrate']
                    self.audio_dl_uri = af['url']

                if af['bitrate'] > last_bitrate:
                    last_bitrate = af['bitrate']
                    self.audio_dl_uri = af['url']

494
495
496
497
        video_quality = "720p"
        self.video_dl_uri = None
        for fs in self.json['formatStreams']:
            if fs['type'].startswith('video/mp4'):
498
499
                # set it to something
                if not self.video_dl_uri:
500
501
502
503
504
505
506
507
508
                    self.video_dl_uri = fs['url']

                if 'qualityLabel' in fs:
                    if fs['qualityLabel'] == "720p" and video_quality == "720p":
                        self.video_dl_uri = fs['url']
                    elif fs['qualityLabel'] == "480p" and video_quality == "480p":
                        self.video_dl_uri = fs['url']
                    elif fs['qualityLabel'] == "1080p" and video_quality == "1080p":
                        self.video_dl_uri = fs['url']
509
510
511
512
513
514

        if self.audio_dl_uri:
            self.audio_dl.set_sensitive(True)

        if self.video_dl_uri:
            self.video_dl.set_sensitive(True)