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:
@@ -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
45
controllers/video.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
BIN
webroot/img/video-settings.png
Normal file
BIN
webroot/img/video-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 465 B |
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user