From fa3b78583fe1d9325cfa43dcee63968f911a1cc4 Mon Sep 17 00:00:00 2001
From: James Taylor <user234683@users.noreply.github.com>
Date: Tue, 24 Aug 2021 22:19:49 -0700
Subject: avmerge: Close streams to avoid errors while changing quality
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

If a fetchRange network request finished after the quality was
changed, there would be a "InvalidStateError: An attempt was made
to use an object that is not, or is no longer, usable" because
appendSegment was trying to append to the sourceBuffer that was
unusable after the video src was changed to a new mediaSource.

Adds a close method to the AVMerge class to properly clean and
close everything so these sorts of errors won't happen.

Signed-off-by: Jesús <heckyel@hyperbola.info>
---
 youtube/static/js/av-merge.js | 47 +++++++++++++++++++++++++++++++++++--------
 youtube/templates/watch.html  |  2 ++
 2 files changed, 41 insertions(+), 8 deletions(-)

diff --git a/youtube/static/js/av-merge.js b/youtube/static/js/av-merge.js
index 393b57d..0435157 100644
--- a/youtube/static/js/av-merge.js
+++ b/youtube/static/js/av-merge.js
@@ -17,7 +17,6 @@
 // SourceBuffer data limits:
 // https://developers.google.com/web/updates/2017/10/quotaexceedederror
 
-// TODO: AVMerge.close()
 // TODO: close stream at end?
 // TODO: Better buffering algorithm
 // TODO: Call abort to cancel in-progress appends?
@@ -61,10 +60,19 @@ AVMerge.prototype.sourceOpen = function(_) {
     this.videoStream.setup();
     this.audioStream.setup();
 
-    this.video.ontimeupdate = this.checkBothBuffers.bind(this);
-    this.video.onseeking = debounce(this.seek.bind(this), 500);
+    this.timeUpdateEvt = addEvent(this.video, 'timeupdate',
+                                  this.checkBothBuffers.bind(this));
+    this.seekingEvt = addEvent(this.video, 'seeking',
+                               debounce(this.seek.bind(this), 500));
     //this.video.onseeked = function() {console.log('seeked')};
 }
+AVMerge.prototype.close = function() {
+    this.videoStream.close();
+    this.audioStream.close();
+    this.timeUpdateEvt.remove();
+    this.seekingEvt.remove();
+    this.mediaSource.endOfStream();
+}
 AVMerge.prototype.checkBothBuffers = function() {
     this.audioStream.checkBuffer();
     this.videoStream.checkBuffer();
@@ -85,6 +93,7 @@ function Stream(avMerge, source, startTime) {
     this.avMerge = avMerge;
     this.video = avMerge.video;
     this.url = source['url'];
+    this.closed = false;
     this.mimeCodec = source['mime_codec']
     this.streamType = source['acodec'] ? 'audio' : 'video';
     if (this.streamType == 'audio') {
@@ -106,8 +115,7 @@ function Stream(avMerge, source, startTime) {
     this.sourceBuffer.addEventListener('error', (e) => {
         this.reportError('sourceBuffer error', e);
     });
-    this.sourceBuffer.addEventListener('updateend', (e) => {
-        this.reportDebug('updateend', e);
+    this.updateendEvt = addEvent(this.sourceBuffer, 'updateend', (e) => {
         if (this.appendQueue.length != 0) {
             this.appendSegment(...this.appendQueue.pop());
         }
@@ -148,12 +156,20 @@ Stream.prototype.setup = async function(){
 Stream.prototype.setupSegments = async function(sidxBox){
     var box = unbox(sidxBox);
     this.sidx = sidx_parse(box.data, this.indexRange.end+1);
-    this.reportDebug('sidx', this.sidx);
-
-    this.reportDebug('appending first segment');
     this.fetchSegmentIfNeeded(this.getSegmentIdx(this.startTime));
 }
+Stream.prototype.close = function() {
+    // Prevents appendSegment adding to buffer if request finishes
+    // after closing
+    this.closed = true;
+    this.sourceBuffer.abort();
+    this.updateendEvt.remove();
+    this.mediaSource.removeSourceBuffer(this.sourceBuffer);
+}
 Stream.prototype.appendSegment = function(segmentIdx, chunk) {
+    if (this.closed)
+        return;
+
     // cannot append right now, schedule for updateend
     if (this.sourceBuffer.updating) {
         this.reportDebug('sourceBuffer updating, queueing for later');
@@ -306,6 +322,7 @@ Stream.prototype.reportError = function(...args) {
 
 
 // Utility functions
+
 function fetchRange(url, start, end, cb) {
     reportDebug('fetchRange', start, end);
     return new Promise((resolve, reject) => {
@@ -342,6 +359,20 @@ function clamp(number, min, max) {
   return Math.max(min, Math.min(number, max));
 }
 
+// allow to remove an event listener without having a function reference
+function RegisteredEvent(obj, eventName, func) {
+    this.obj = obj;
+    this.eventName = eventName;
+    this.func = func;
+    obj.addEventListener(eventName, func);
+}
+RegisteredEvent.prototype.remove = function() {
+    this.obj.removeEventListener(this.eventName, this.func);
+}
+function addEvent(obj, eventName, func) {
+    return new RegisteredEvent(obj, eventName, func);
+}
+
 function reportWarning(...args){
     console.log(...args);
 }
diff --git a/youtube/templates/watch.html b/youtube/templates/watch.html
index 2c85e16..912237a 100644
--- a/youtube/templates/watch.html
+++ b/youtube/templates/watch.html
@@ -119,6 +119,8 @@
                          var videoPaused = video.paused;
                          var videoSpeed = video.playbackRate;
                          var videoSource;
+                         if (avMerge)
+                             avMerge.close();
                          if (selection.type == 'uni'){
                              videoSource = data['uni_sources'][selection.index];
                              video.src = videoSource.url;
-- 
cgit v1.2.3