From c713e216d38326f159742d8cf2a0d6cc11c4ba21 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 11 Mar 2021 12:51:43 -0800 Subject: [PATCH] Allow selection of different stream variants in the player (#815) * WIP video quality selector * The quality selector works even though it is not pretty * Support getting and setting variant name. Closes #743 * Sort video qualities * Fix odd looking selected states of menubutton items * Fix comment --- controllers/admin/serverConfig.go | 1 + controllers/video.go | 45 +++++++++++++++ models/streamOutputVariant.go | 46 ++++++++++++++- router/router.go | 3 + webroot/img/video-settings.png | Bin 0 -> 465 bytes webroot/js/components/player.js | 90 +++++++++++++++++++++++++++++- webroot/styles/video.css | 4 ++ 7 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 controllers/video.go create mode 100644 webroot/img/video-settings.png diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index f47323f51..97ce35f9a 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -16,6 +16,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { var videoQualityVariants = make([]models.StreamOutputVariant, 0) for _, variant := range data.GetStreamOutputVariants() { videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{ + Name: variant.GetName(), IsAudioPassthrough: variant.GetIsAudioPassthrough(), IsVideoPassthrough: variant.IsVideoPassthrough, Framerate: variant.GetFramerate(), diff --git a/controllers/video.go b/controllers/video.go new file mode 100644 index 000000000..60af7a5e6 --- /dev/null +++ b/controllers/video.go @@ -0,0 +1,45 @@ +package controllers + +import ( + "net/http" + "sort" + + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" +) + +type variants []models.StreamOutputVariant + +type variantsResponse struct { + Name string `json:"name"` + Index int `json:"index"` +} + +// Len returns the number of variants. +func (v variants) Len() int { return len(v) } + +// Less is less than.. +func (v variants) Less(i, j int) bool { return v[i].VideoBitrate < v[j].VideoBitrate } + +// Swap will swap two values. +func (v variants) Swap(i, j int) { v[i], v[j] = v[j], v[i] } + +// GetVideoStreamOutputVariants will return the video variants available, +func GetVideoStreamOutputVariants(w http.ResponseWriter, r *http.Request) { + outputVariants := data.GetStreamOutputVariants() + result := make([]variantsResponse, len(outputVariants)) + + for i, variant := range outputVariants { + variantResponse := variantsResponse{ + Index: i, + Name: variant.GetName(), + } + result[i] = variantResponse + } + + sort.Slice(result, func(i, j int) bool { + return outputVariants[i].VideoBitrate > outputVariants[j].VideoBitrate || !outputVariants[i].IsVideoPassthrough + }) + + WriteResponse(w, result) +} diff --git a/models/streamOutputVariant.go b/models/streamOutputVariant.go index b622ed21d..bb91ec8d8 100644 --- a/models/streamOutputVariant.go +++ b/models/streamOutputVariant.go @@ -1,9 +1,16 @@ package models -import "encoding/json" +import ( + "encoding/json" + "fmt" + "math" +) // StreamOutputVariant defines the output specifics of a single HLS stream variant. type StreamOutputVariant struct { + // Name is an optional human-readable label for this stream output. + Name string `json:"name"` + // Enable passthrough to copy the video and/or audio directly from the // incoming stream and disable any transcoding. It will ignore any of // the below settings. @@ -76,6 +83,43 @@ func (q *StreamOutputVariant) GetIsAudioPassthrough() bool { return false } +// GetName will return the human readable name for this stream output. +func (q *StreamOutputVariant) GetName() string { + bitrate := getBitrateString(q.VideoBitrate) + + if q.Name != "" { + return q.Name + } else if q.IsVideoPassthrough { + return "Source" + } else if q.ScaledHeight == 720 && q.ScaledWidth == 1080 { + return fmt.Sprintf("720p @%s", bitrate) + } else if q.ScaledHeight == 1080 && q.ScaledWidth == 1920 { + return fmt.Sprintf("1080p @%s", bitrate) + } else if q.ScaledHeight != 0 { + return fmt.Sprintf("%dh", q.ScaledHeight) + } else if q.ScaledWidth != 0 { + return fmt.Sprintf("%dw", q.ScaledWidth) + } else { + return fmt.Sprintf("%s@%dfps", bitrate, q.Framerate) + } +} + +func getBitrateString(bitrate int) string { + if bitrate == 0 { + return "" + } else if bitrate < 1000 { + return fmt.Sprintf("%dKbps", bitrate) + } else if bitrate >= 1000 { + if math.Mod(float64(bitrate), 1000) == 0 { + return fmt.Sprintf("%dMbps", bitrate/1000.0) + } else { + return fmt.Sprintf("%.1fMbps", float32(bitrate)/1000.0) + } + } + + return "" +} + // MarshalJSON is a custom JSON marshal function for video stream qualities. func (q *StreamOutputVariant) MarshalJSON() ([]byte, error) { type Alias StreamOutputVariant diff --git a/router/router.go b/router/router.go index 2a0816f7a..6fa042404 100644 --- a/router/router.go +++ b/router/router.go @@ -58,6 +58,9 @@ func Start() error { // return the logo http.HandleFunc("/logo", controllers.GetLogo) + // return the list of video variants available + http.HandleFunc("/api/video/variants", controllers.GetVideoStreamOutputVariants) + // Authenticated admin requests // Current inbound broadcaster diff --git a/webroot/img/video-settings.png b/webroot/img/video-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..d37f489d88e3e206bbab7ff7680ad957de92f384 GIT binary patch literal 465 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4i*LmhONKMUokK+u%tWsIx;Y9?C1WI$jZRL zppfhlX?_wfUqO7#P?~Jbhi+pEB{Y3Nm)R-gJtAfnmOxSiLx%ThPccp=uOFi9=?#;zs~v!-v~)-7_~5L&>&>2he}1(wA-8n*8^ z`tw}v0r_dk$M-Ml+ZfBuz5Cd<{jF=OE{ETrvL{lKT_K(IOvB^nHA$=d80M^6=^1-t zd}X?Wvww2 zI|SaT)-v$bN3@rE-rB;zz@S><8c~vxSdwa$T$GwvlFDFYU}UIkV5Dnk8e(8-WoT|? zV5n_iU}a#y^);^pMMG|WN@iLmZVlBdu9+|}Fi3)I2+mI{DNig)WhgH%*UQYyE>2D? aNY%?PN}v7CMv8%ffx*+&&t;ucLK6TPahxpx literal 0 HcmV?d00001 diff --git a/webroot/js/components/player.js b/webroot/js/components/player.js index 60a257671..3973cba39 100644 --- a/webroot/js/components/player.js +++ b/webroot/js/components/player.js @@ -25,6 +25,7 @@ const VIDEO_OPTIONS = { vhs: { // used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default. enableLowInitialPlaylist: true, + smoothQualityChange: true, }, }, liveTracker: { @@ -53,10 +54,16 @@ class OwncastPlayer { this.handleVolume = this.handleVolume.bind(this); this.handleEnded = this.handleEnded.bind(this); this.handleError = this.handleError.bind(this); + this.addQualitySelector = this.addQualitySelector.bind(this); + + this.qualitySelectionMenu = null; } init() { - videojs.Vhs.xhr.beforeRequest = options => { + this.addAirplay(); + this.addQualitySelector(); + + videojs.Vhs.xhr.beforeRequest = (options) => { if (options.uri.match('m3u8')) { const cachebuster = Math.round(new Date().getTime() / 1000); options.uri = `${options.uri}?cachebust=${cachebuster}`; @@ -66,7 +73,6 @@ class OwncastPlayer { this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS); - this.addAirplay(); this.vjsPlayer.ready(this.handleReady); } @@ -103,7 +109,10 @@ class OwncastPlayer { } handleVolume() { - setLocalStorage(PLAYER_VOLUME, this.vjsPlayer.muted() ? 0 : this.vjsPlayer.volume()); + setLocalStorage( + PLAYER_VOLUME, + this.vjsPlayer.muted() ? 0 : this.vjsPlayer.volume() + ); } handlePlaying() { @@ -132,6 +141,81 @@ class OwncastPlayer { // console.log(`>>> Player: ${message}`); } + async addQualitySelector() { + if (this.qualityMenuButton) { + player.controlBar.removeChild(this.qualityMenuButton) + } + + videojs.hookOnce( + 'setup', + async function (player) { + var qualities = []; + + try { + const response = await fetch("/api/video/variants"); + qualities = await response.json(); + } catch(e) { + console.log(e); + } + + var MenuItem = videojs.getComponent('MenuItem'); + var MenuButtonClass = videojs.getComponent('MenuButton'); + var MenuButton = videojs.extend(MenuButtonClass, { + // The `init()` method will also work for constructor logic here, but it is + // deprecated. If you provide an `init()` method, it will override the + // `constructor()` method! + constructor: function () { + MenuButtonClass.call(this, player); + }, + + handleClick: function () { + }, + + createItems: function () { + const defaultAutoItem = new MenuItem(player, { + selectable: true, + label: 'Auto', + }); + + const items = qualities.map(function (item) { + var newMenuItem = new MenuItem(player, { + selectable: true, + label: item.name, + }); + + // Quality selected + newMenuItem.on('click', function () { + // Only enable this single, selected representation. + player.tech({ IWillNotUseThisInPlugins: true }).vhs.representations().forEach(function(rep, index) { + rep.enabled(index === item.index); + }); + newMenuItem.selected(false) + }); + + return newMenuItem; + }); + + defaultAutoItem.on('click', function () { + // Re-enable all representations. + player.tech({ IWillNotUseThisInPlugins: true }).vhs.representations().forEach(function(rep, index) { + rep.enabled(true); + }); + defaultAutoItem.selected(false) + }); + + return [defaultAutoItem, ...items]; + }, + }); + + var menuButton = new MenuButton(); + menuButton.addClass('vjs-quality-selector'); + player.controlBar.addChild(menuButton, {}, player.controlBar.children_.length -2 ); + + this.qualityMenuButton = menuButton; + }.bind(this) + ); + } + addAirplay() { videojs.hookOnce('setup', function (player) { if (window.WebKitPlaybackTargetAvailabilityEvent) { diff --git a/webroot/styles/video.css b/webroot/styles/video.css index bdf431c9a..5fda3b267 100644 --- a/webroot/styles/video.css +++ b/webroot/styles/video.css @@ -14,6 +14,10 @@ video.video-js { content: url("../img/airplay.png"); } +.vjs-quality-selector .vjs-icon-placeholder::before { + content: url("../img/video-settings.png"); +} + .vjs-big-play-button { z-index: 100; }