results.py 18.4 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
223
            # is negative number when not successful, so put it to 0
            if not success:
                position_value = 0
            
224
225
226
227
228
229
230
231
            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

232
233
        return True

234
235
236
237
238
239
240
241
242
    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:
243
            self.show_error_icon('audio')
244
245
246
247
248
249
            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,
250
                GLib.PRIORITY_LOW, None,
251
252
253
254
255
256
257
258
259
                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:
260
            self.show_error_icon('video')
261
262
263
264
265
            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)
266
267
268
        flags = Gio.FileCopyFlags
        dl_stream.copy_async(dest,
                # bitwise or (not tuple) for multiple flags
269
270
271
                Gio.FileCopyFlags.OVERWRITE |
                Gio.FileCopyFlags.ALL_METADATA |
                Gio.FileCopyFlags.TARGET_DEFAULT_PERMS,
272
                GLib.PRIORITY_LOW, None,
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
                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')

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

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

    @Gtk.Template.Callback()
    def play_button(self, button):
332
333
334
        # loop through all child results pausing them
        self.app_window.pause_all()

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

        # 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
351
        GLib.timeout_add_seconds(1, self.update_slider)
352

353
    def null_out_player(self):
354
        self.inactivate_player()
355
356
        self.player.set_state(Gst.State.NULL)

357
358
    @Gtk.Template.Callback()
    def pause_button(self, button):
359
360
361
362
        self.inactivate_player()
        self.player.set_state(Gst.State.PAUSED)

    def inactivate_player(self):
363
364
        self.play.set_visible(True)
        self.pause.set_visible(False)
365
        self.app_window.is_playing = False
366
        self.app_window.uninhibit_app()
367
368
369
370
371

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

372
373
374
375
    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
376
377
378
    def delay_grab(self):
        self.grab_focus()

379
380
    @Gtk.Template.Callback()
    def fullscreen_button(self, button):
381
382
383
        self.fullscreen.set_visible(False)
        self.unfullscreen.set_visible(True)
        self.app_window.fullscreen()
384
        self.app_window.is_fullscreen = True
385
386
387
388
389
390
391
392
393
394
395
396
397
        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
398
399
400
401
        # grabbing happens before resize completes
        # adding a slight delay to grab focus after resize completes
        GLib.timeout_add(50, self.delay_grab)

402
        # horizonal scrollbar, vertical scrollbar (do last)
403
404
        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
405
        scroller.set_kinetic_scrolling(False)
406

407
408
409
410
411
    @Gtk.Template.Callback()
    def unfullscreen_button(self, button):
        self.fullscreen.set_visible(True)
        self.unfullscreen.set_visible(False)
        self.app_window.unfullscreen()
412
        self.app_window.is_fullscreen = False
413
414
415
416
        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)
417

418
419
420
421
422
423
424
425
426
        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)
427
428
429

        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
430
        scroller.set_kinetic_scrolling(True)
431

432
433
        self.grab_focus()

434
435
436
437
438
439
440
441
    @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)
442
443

    def poll_mouse(self):
444
445
446
447
        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)
448
449

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

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

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

    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']

491
492
493
494
        video_quality = "720p"
        self.video_dl_uri = None
        for fs in self.json['formatStreams']:
            if fs['type'].startswith('video/mp4'):
495
496
                # set it to something
                if not self.video_dl_uri:
497
498
499
500
501
502
503
504
505
                    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']
506
507
508
509
510
511

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

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