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
This commit is contained in:
Gabe Kangas
2021-03-11 12:51:43 -08:00
committed by GitHub
parent 145744c381
commit c713e216d3
7 changed files with 185 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
var videoQualityVariants = make([]models.StreamOutputVariant, 0) var videoQualityVariants = make([]models.StreamOutputVariant, 0)
for _, variant := range data.GetStreamOutputVariants() { for _, variant := range data.GetStreamOutputVariants() {
videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{ videoQualityVariants = append(videoQualityVariants, models.StreamOutputVariant{
Name: variant.GetName(),
IsAudioPassthrough: variant.GetIsAudioPassthrough(), IsAudioPassthrough: variant.GetIsAudioPassthrough(),
IsVideoPassthrough: variant.IsVideoPassthrough, IsVideoPassthrough: variant.IsVideoPassthrough,
Framerate: variant.GetFramerate(), Framerate: variant.GetFramerate(),

45
controllers/video.go Normal file
View File

@@ -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)
}

View File

@@ -1,9 +1,16 @@
package models package models
import "encoding/json" import (
"encoding/json"
"fmt"
"math"
)
// StreamOutputVariant defines the output specifics of a single HLS stream variant. // StreamOutputVariant defines the output specifics of a single HLS stream variant.
type StreamOutputVariant struct { 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 // Enable passthrough to copy the video and/or audio directly from the
// incoming stream and disable any transcoding. It will ignore any of // incoming stream and disable any transcoding. It will ignore any of
// the below settings. // the below settings.
@@ -76,6 +83,43 @@ func (q *StreamOutputVariant) GetIsAudioPassthrough() bool {
return false 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. // MarshalJSON is a custom JSON marshal function for video stream qualities.
func (q *StreamOutputVariant) MarshalJSON() ([]byte, error) { func (q *StreamOutputVariant) MarshalJSON() ([]byte, error) {
type Alias StreamOutputVariant type Alias StreamOutputVariant

View File

@@ -58,6 +58,9 @@ func Start() error {
// return the logo // return the logo
http.HandleFunc("/logo", controllers.GetLogo) http.HandleFunc("/logo", controllers.GetLogo)
// return the list of video variants available
http.HandleFunc("/api/video/variants", controllers.GetVideoStreamOutputVariants)
// Authenticated admin requests // Authenticated admin requests
// Current inbound broadcaster // Current inbound broadcaster

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

View File

@@ -25,6 +25,7 @@ const VIDEO_OPTIONS = {
vhs: { vhs: {
// used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default. // used to select the lowest bitrate playlist initially. This helps to decrease playback start time. This setting is false by default.
enableLowInitialPlaylist: true, enableLowInitialPlaylist: true,
smoothQualityChange: true,
}, },
}, },
liveTracker: { liveTracker: {
@@ -53,10 +54,16 @@ class OwncastPlayer {
this.handleVolume = this.handleVolume.bind(this); this.handleVolume = this.handleVolume.bind(this);
this.handleEnded = this.handleEnded.bind(this); this.handleEnded = this.handleEnded.bind(this);
this.handleError = this.handleError.bind(this); this.handleError = this.handleError.bind(this);
this.addQualitySelector = this.addQualitySelector.bind(this);
this.qualitySelectionMenu = null;
} }
init() { init() {
videojs.Vhs.xhr.beforeRequest = options => { this.addAirplay();
this.addQualitySelector();
videojs.Vhs.xhr.beforeRequest = (options) => {
if (options.uri.match('m3u8')) { if (options.uri.match('m3u8')) {
const cachebuster = Math.round(new Date().getTime() / 1000); const cachebuster = Math.round(new Date().getTime() / 1000);
options.uri = `${options.uri}?cachebust=${cachebuster}`; options.uri = `${options.uri}?cachebust=${cachebuster}`;
@@ -66,7 +73,6 @@ class OwncastPlayer {
this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS); this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS);
this.addAirplay();
this.vjsPlayer.ready(this.handleReady); this.vjsPlayer.ready(this.handleReady);
} }
@@ -103,7 +109,10 @@ class OwncastPlayer {
} }
handleVolume() { handleVolume() {
setLocalStorage(PLAYER_VOLUME, this.vjsPlayer.muted() ? 0 : this.vjsPlayer.volume()); setLocalStorage(
PLAYER_VOLUME,
this.vjsPlayer.muted() ? 0 : this.vjsPlayer.volume()
);
} }
handlePlaying() { handlePlaying() {
@@ -132,6 +141,81 @@ class OwncastPlayer {
// console.log(`>>> Player: ${message}`); // 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() { addAirplay() {
videojs.hookOnce('setup', function (player) { videojs.hookOnce('setup', function (player) {
if (window.WebKitPlaybackTargetAvailabilityEvent) { if (window.WebKitPlaybackTargetAvailabilityEvent) {

View File

@@ -14,6 +14,10 @@ video.video-js {
content: url("../img/airplay.png"); content: url("../img/airplay.png");
} }
.vjs-quality-selector .vjs-icon-placeholder::before {
content: url("../img/video-settings.png");
}
.vjs-big-play-button { .vjs-big-play-button {
z-index: 100; z-index: 100;
} }