initial commit
This commit is contained in:
commit
18078d1d3b
34
api.go
Normal file
34
api.go
Normal file
@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.milar.in/milarin/adverr"
|
||||
"git.milar.in/milarin/ezhttp"
|
||||
)
|
||||
|
||||
var BaseURL = adverr.Must(url.Parse("http://desktop:30124"))
|
||||
|
||||
func ProxyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
req := ezhttp.Request(
|
||||
ezhttp.Method(r.Method),
|
||||
ezhttp.URL(BaseURL.JoinPath(r.URL.Path).String()),
|
||||
ezhttp.Body(ezhttp.Reader(r.Body)),
|
||||
ezhttp.ContentType("application/json"),
|
||||
)
|
||||
|
||||
resp, err := ezhttp.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module git.milar.in/milarin/yt-remote-control
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require (
|
||||
git.milar.in/milarin/adverr v1.1.1
|
||||
git.milar.in/milarin/ezhttp v0.0.2
|
||||
)
|
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
||||
git.milar.in/milarin/adverr v1.1.1 h1:ENtBcqT7CncLsVfaLC3KzX8QSSGiSpsC7I7wDqladu8=
|
||||
git.milar.in/milarin/adverr v1.1.1/go.mod h1:joU9sBb7ySyNv4SpTXB0Z4o1mjXsArBw4N27wjgzj9E=
|
||||
git.milar.in/milarin/ezhttp v0.0.2 h1:9aVBd3AW6XAqWLPDrWrJr2r4JFa21n/B1MpAVN80opU=
|
||||
git.milar.in/milarin/ezhttp v0.0.2/go.mod h1:7v6RMtAvImUllDDsYGVP4C0BhO1yxLarVorttRGGyJ0=
|
32
main.go
Normal file
32
main.go
Normal file
@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var StaticFilesDir embed.FS
|
||||
|
||||
var ( // flags
|
||||
FlagInterface = flag.String("i", "", "network interface")
|
||||
FlagPort = flag.Uint("p", 8080, "network port")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
staticFiles, err := fs.Sub(StaticFilesDir, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
http.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(ProxyRequest)))
|
||||
http.Handle("/", http.FileServer(http.FS(staticFiles)))
|
||||
|
||||
http.ListenAndServe(fmt.Sprintf("%s:%d", *FlagInterface, *FlagPort), nil)
|
||||
}
|
117
static/css/dialog.css
Normal file
117
static/css/dialog.css
Normal file
@ -0,0 +1,117 @@
|
||||
dialog {
|
||||
margin: auto;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(10px);
|
||||
color: white;
|
||||
box-shadow: 1px 1px 10px 10px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 0.25em;
|
||||
}
|
||||
|
||||
dialog .header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
dialog .header h1 {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
dialog .header span.close {
|
||||
font-size: 2rem;
|
||||
margin-left: 0.5em;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
dialog .header span.close:hover {
|
||||
transform: scale(1.2);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
dialog .body form {
|
||||
padding: 0.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog .body form input[type="radio"],
|
||||
dialog .body form input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
dialog .body form label {
|
||||
padding: 0.75em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
dialog .body form input[type="radio"]:checked + label {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
dialog .body form input[type="url"] {
|
||||
background-color: #111;
|
||||
color: white;
|
||||
padding: 0.75em;
|
||||
border: 1px solid #333;
|
||||
margin-bottom: 1em;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
dialog .body form input[type="url"]:focus {
|
||||
border: 1px solid #555;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
dialog .body form button {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
padding: 0.75em;
|
||||
font-size: 1rem;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
dialog .body form button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
dialog:not([open]) {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
/* var(--video-card-width) * 2 + var(--video-card-gap) * 3 */
|
||||
@media (max-width: 51.5em) {
|
||||
dialog {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* dialog-specific rules */
|
||||
|
||||
#close-video-dialog {
|
||||
max-width: 30em;
|
||||
}
|
||||
|
||||
#close-video-dialog .body p {
|
||||
text-align: center;
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
#close-video-dialog-title {
|
||||
background-color: #111;
|
||||
border: 1px solid #333;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
173
static/css/main.css
Normal file
173
static/css/main.css
Normal file
@ -0,0 +1,173 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
/*
|
||||
when changing these values,
|
||||
both media queries in dialog.css and video.css
|
||||
should be changed accordingly as well.
|
||||
*/
|
||||
--video-card-width: 25em;
|
||||
--video-card-gap: 0.5em;
|
||||
|
||||
--header-height: 3em;
|
||||
--footer-height: 6em;
|
||||
--footer-padding: 0.5em;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #111;
|
||||
font-size: 100%;
|
||||
display: block;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
header {
|
||||
display: absolute;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(200, 55, 55, 0.9);
|
||||
box-shadow: 0 0 0.5em 0.5em rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
color: white;
|
||||
height: var(--header-height);
|
||||
padding: 0 0.25em;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
header span.material-symbols-outlined {
|
||||
font-size: 2em;
|
||||
line-height: 3rem;
|
||||
float: right;
|
||||
padding: 0 0.25em;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
header span.material-symbols-outlined:hover {
|
||||
transform: scale(1.2);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
#video-container {
|
||||
margin-top: var(--header-height);
|
||||
margin-bottom: calc(var(--footer-height) + var(--footer-padding) * 2);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-areas:
|
||||
"current-timestamp seekbar seekbar seekbar seekbar seekbar video-length"
|
||||
". . replay pause forward volume-bar volume-bar";
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(17, 17, 17, 0.95);
|
||||
box-shadow: 0 0 0.5em 0.5em rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(3px);
|
||||
padding: var(--footer-padding);
|
||||
z-index: 11;
|
||||
height: var(--footer-height);
|
||||
}
|
||||
|
||||
#seekbar {
|
||||
--margin: 0.75em;
|
||||
grid-area: seekbar;
|
||||
align-self: center;
|
||||
width: calc(100% - var(--margin) * 2);
|
||||
margin: 0.5em var(--margin);
|
||||
}
|
||||
|
||||
#replay {
|
||||
grid-area: replay;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#play-pause {
|
||||
grid-area: pause;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin: 0 0.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#forward {
|
||||
grid-area: forward;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#volume-bar {
|
||||
--margin: 0.75em;
|
||||
grid-area: volume-bar;
|
||||
max-width: 15em;
|
||||
width: calc(100% - var(--margin) * 2);
|
||||
margin: 0 var(--margin);
|
||||
justify-self: right;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#play-pause .material-symbols-outlined {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
#play-pause:disabled,
|
||||
#replay:disabled,
|
||||
#forward:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
#current-timestamp {
|
||||
grid-area: current-timestamp;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#video-length {
|
||||
grid-area: video-length;
|
||||
color: white;
|
||||
}
|
||||
|
||||
footer .material-symbols-outlined {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
height: 0.5em;
|
||||
background-color: #333;
|
||||
border-radius: 0.25em;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb,
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(200, 55, 55);
|
||||
border: none;
|
||||
}
|
87
static/css/video.css
Normal file
87
static/css/video.css
Normal file
@ -0,0 +1,87 @@
|
||||
#video-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--video-card-gap);
|
||||
padding: var(--video-card-gap);
|
||||
}
|
||||
|
||||
.video-item {
|
||||
width: var(--video-card-width);
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-image:not(.shown-on-display) {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
|
||||
.video-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
user-select: none;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
height: 3px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-progress .primary {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
background-color: #ee1111;
|
||||
}
|
||||
|
||||
.video-progress .secondary {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: #994444;
|
||||
}
|
||||
|
||||
.video-close {
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 1.75rem;
|
||||
padding: 0.1em;
|
||||
text-shadow: 0px 0px 0.5em black;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.video-close:hover {
|
||||
transform: scale(1.2);
|
||||
text-shadow: 0px 0px 0.25em black;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
/* var(--video-card-width) * 2 + var(--video-card-gap) * 3 */
|
||||
@media (max-width: 51.5em) {
|
||||
.video-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
102
static/index.html
Normal file
102
static/index.html
Normal file
@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Youtube RemoteControl</title>
|
||||
<script src="js/element.js"></script>
|
||||
<script src="js/youtube.js"></script>
|
||||
<script src="js/video.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
<link rel="stylesheet" href="css/dialog.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link rel="stylesheet" href="css/video.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<template id="video-template">
|
||||
<div class="video-item">
|
||||
<img class="video-image" src="">
|
||||
<span class="video-close material-symbols-outlined">close</span>
|
||||
<div class="video-bottom">
|
||||
<p class="video-title"></p>
|
||||
<div class="video-progress">
|
||||
<div class="primary"></div>
|
||||
<div class="secondary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<dialog id="display-select-dialog">
|
||||
<div class="header">
|
||||
<h1>Select Active Display</h1>
|
||||
<span onClick="onCloseSelectDisplayDialogClicked()" class="close material-symbols-outlined">close</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<form id="display-select-dialog-form"></form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="audio-sink-select-dialog">
|
||||
<div class="header">
|
||||
<h1>Select Active Audio Sink</h1>
|
||||
<span onClick="onCloseSelectAudioSinkDialogClicked()" class="close material-symbols-outlined">close</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<form id="audio-sink-select-dialog-form"></form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="open-new-video-dialog">
|
||||
<div class="header">
|
||||
<h1>Open New Video</h1>
|
||||
<span onClick="onCloseOpenNewVideoDialogClicked()" class="close material-symbols-outlined">close</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<form id="open-new-video-dialog-form">
|
||||
<input name="link" id="open-new-video-dialog-link" type="url" placeholder="URL"></input>
|
||||
<button onClick="onOpenVideoInBackgroundButtonClicked()" type="button">Open video in background</button>
|
||||
<button onClick="onOpenVideoButtonClicked()" type="button">Open video</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="close-video-dialog">
|
||||
<div class="header">
|
||||
<h1>Close Video</h1>
|
||||
<span onClick="onCloseCloseVideoDialogClicked()" class="close material-symbols-outlined">close</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<form id="close-video-dialog-form">
|
||||
<p>Do you want to close the following video?</p>
|
||||
<p id="close-video-dialog-title"></p>
|
||||
<button onClick="onCloseVideoButtonClicked()" type="button">Close</button>
|
||||
<button onClick="onCancelCloseVideoButtonClicked()" type="button">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<header>
|
||||
<span onClick="onShowSelectDisplayDialogClicked()" class="material-symbols-outlined">tv_displays</span>
|
||||
<span onClick="onShowSelectAudioSinkDialogClicked()" class="material-symbols-outlined">speaker_group</span>
|
||||
<span onClick="onShowOpenNewVideoDialogClicked()" class="material-symbols-outlined">open_in_new</span>
|
||||
</header>
|
||||
|
||||
<div id="video-container"></div>
|
||||
|
||||
<footer>
|
||||
<p id="current-timestamp"></p>
|
||||
<input type="range" min="0" max="100" value="0" class="slider" id="seekbar">
|
||||
<p id="video-length"></p>
|
||||
<button id="replay" type="button"><span class="material-symbols-outlined">replay_10</span></button>
|
||||
<button id="play-pause" type="button"><span class="material-symbols-outlined">play_circle</span></button>
|
||||
<button id="forward" type="button"><span class="material-symbols-outlined">forward_10</span></button>
|
||||
<input type="range" min="0" max="100" value="0" step="5" class="slider" id="volume-bar">
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
48
static/js/element.js
Normal file
48
static/js/element.js
Normal file
@ -0,0 +1,48 @@
|
||||
Element.prototype.insertChildAtIndex = function(child, index) {
|
||||
if (!index) index = 0;
|
||||
|
||||
if (index >= this.children.length) {
|
||||
this.appendChild(child);
|
||||
} else {
|
||||
this.insertBefore(child, this.children[index]);
|
||||
}
|
||||
}
|
||||
|
||||
Element.prototype.clearChildren = function() {
|
||||
while (this.firstChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.pad = function(size) {
|
||||
var s = String(this);
|
||||
while (s.length < (size || 2)) {s = "0" + s;}
|
||||
return s;
|
||||
}
|
||||
|
||||
window.addEventListener("load", init);
|
||||
|
||||
function init() {
|
||||
const sliders = document.querySelectorAll('input[type="range"]');
|
||||
for (let slider of sliders) {
|
||||
let changeFunction = event => {
|
||||
const progress = event.target.value;
|
||||
const max = event.target.max;
|
||||
const value = Math.round(progress / max * 100);
|
||||
slider.style.background = `linear-gradient(90deg, rgba(200,55,55,1) ${value}%, #333 ${value}%)`;
|
||||
};
|
||||
|
||||
slider.setValue = value => {
|
||||
if (slider.mousedown) return;
|
||||
slider.value = value;
|
||||
changeFunction({target: slider});
|
||||
}
|
||||
|
||||
slider.addEventListener("mousedown", event => slider.mousedown = true);
|
||||
slider.addEventListener("mouseup", event => slider.mousedown = false);
|
||||
|
||||
slider.addEventListener("input", changeFunction);
|
||||
slider.addEventListener("change", changeFunction);
|
||||
changeFunction({target: slider});
|
||||
}
|
||||
}
|
270
static/js/main.js
Normal file
270
static/js/main.js
Normal file
@ -0,0 +1,270 @@
|
||||
window.addEventListener("load", main);
|
||||
|
||||
let displaySelectDialog;
|
||||
let audioSinkSelectDialog;
|
||||
let openNewVideoDialog;
|
||||
let closeVideoDialog;
|
||||
|
||||
let closeVideoDialogID;
|
||||
|
||||
let videoTemplate;
|
||||
let videoContainer;
|
||||
let currentVideos = [];
|
||||
let currentVideoData;
|
||||
|
||||
let seekbar;
|
||||
let volumeBar;
|
||||
let playPauseButton;
|
||||
let playPauseButtonIcon;
|
||||
let replayButton;
|
||||
let forwardButton;
|
||||
let currentTimestamp;
|
||||
let videoLength;
|
||||
|
||||
async function main() {
|
||||
displaySelectDialog = document.querySelector("#display-select-dialog");
|
||||
audioSinkSelectDialog = document.querySelector("#audio-sink-select-dialog");
|
||||
openNewVideoDialog = document.querySelector("#open-new-video-dialog");
|
||||
closeVideoDialog = document.querySelector("#close-video-dialog");
|
||||
|
||||
videoTemplate = document.querySelector("#video-template");
|
||||
videoContainer = document.querySelector("#video-container");
|
||||
|
||||
seekbar = document.querySelector("#seekbar");
|
||||
volumeBar = document.querySelector("#volume-bar");
|
||||
playPauseButton = document.querySelector("#play-pause");
|
||||
playPauseButtonIcon = playPauseButton.querySelector(".material-symbols-outlined");
|
||||
replayButton = document.querySelector("#replay");
|
||||
forwardButton = document.querySelector("#forward");
|
||||
currentTimestamp = document.querySelector("#current-timestamp");
|
||||
videoLength = document.querySelector("#video-length");
|
||||
|
||||
playPauseButton.addEventListener("click", onPlayPauseButtonClicked);
|
||||
replayButton.addEventListener("click", onReplayButtonClicked);
|
||||
forwardButton.addEventListener("click", onForwardButtonClicked);
|
||||
seekbar.addEventListener("change", onSeekbarChanged);
|
||||
volumeBar.addEventListener("input", onVolumeBarChanged);
|
||||
|
||||
updateUI();
|
||||
setInterval(updateUI, 1000);
|
||||
}
|
||||
|
||||
async function updateUI() {
|
||||
const videoDatas = await listVideos();
|
||||
const currentDisplay = localStorage.getItem("display");
|
||||
currentVideoData = videoDatas.find(videoData => videoData.display == currentDisplay);
|
||||
|
||||
if (currentVideoData !== undefined) {
|
||||
console.log(currentVideoData);
|
||||
|
||||
seekbar.disabled = false;
|
||||
volumeBar.disabled = false;
|
||||
playPauseButton.disabled = false;
|
||||
replayButton.disabled = false;
|
||||
forwardButton.disabled = false;
|
||||
|
||||
const {progress, duration} = formatTimestamps(currentVideoData.progress, currentVideoData.duration);
|
||||
currentTimestamp.textContent = progress;
|
||||
videoLength.textContent = duration;
|
||||
seekbar.max = currentVideoData.duration;
|
||||
seekbar.setValue(currentVideoData.progress);
|
||||
volumeBar.setValue(currentVideoData.volume);
|
||||
playPauseButtonIcon.textContent = currentVideoData.paused ? "play_circle" : "pause_circle";
|
||||
} else {
|
||||
seekbar.disabled = true;
|
||||
seekbar.setValue(0);
|
||||
volumeBar.disabled = true;
|
||||
playPauseButton.disabled = true;
|
||||
playPauseButtonIcon.textContent = "play_circle";
|
||||
replayButton.disabled = true;
|
||||
forwardButton.disabled = true;
|
||||
currentTimestamp.textContent = "";
|
||||
videoLength.textContent = "";
|
||||
}
|
||||
|
||||
await refreshVideoList(videoDatas);
|
||||
}
|
||||
|
||||
async function refreshVideoList(videoDatas) {
|
||||
const videoIDs = videoDatas.map(videoData => videoData.id);
|
||||
const shownVideoIDs = currentVideos.map(video => video.videoID);
|
||||
const missingVideoIDs = videoIDs.filter(videoID => !shownVideoIDs.includes(videoID));
|
||||
const unneededVideoIDs = shownVideoIDs.filter(shownVideoID => !videoIDs.includes(shownVideoID));
|
||||
|
||||
for (let unneededVideoID of unneededVideoIDs) {
|
||||
const index = currentVideos.findIndex(video => video.videoID == unneededVideoID);
|
||||
const video = currentVideos[index];
|
||||
video.hide();
|
||||
currentVideos.splice(index, 1);
|
||||
}
|
||||
|
||||
for (let index in videoDatas) {
|
||||
const videoData = videoDatas[index];
|
||||
|
||||
if (missingVideoIDs.includes(videoData.id)) {
|
||||
const video = new Video(videoData);
|
||||
video.showAt(index);
|
||||
currentVideos.splice(index, 0, video);
|
||||
} else {
|
||||
const video = currentVideos.find(currentVideo => currentVideo.videoID == videoData.id);
|
||||
video.update(videoData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onShowSelectDisplayDialogClicked() {
|
||||
const displays = await listDisplays();
|
||||
const currentDisplay = localStorage.getItem("display");
|
||||
|
||||
const displaySelectForm = document.querySelector("#display-select-dialog-form");
|
||||
displaySelectForm.clearChildren();
|
||||
|
||||
for (let display of displays) {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "radio");
|
||||
input.setAttribute("name", "display");
|
||||
input.setAttribute("id", `display_${display}`);
|
||||
input.setAttribute("value", display);
|
||||
input.addEventListener("change", onCloseSelectDisplayDialogClicked);
|
||||
if (display == currentDisplay) input.setAttribute("checked", "checked");
|
||||
displaySelectForm.appendChild(input);
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.setAttribute("for", `display_${display}`);
|
||||
label.textContent = display;
|
||||
displaySelectForm.appendChild(label);
|
||||
}
|
||||
|
||||
displaySelectDialog.showModal();
|
||||
}
|
||||
|
||||
function onCloseSelectDisplayDialogClicked() {
|
||||
const selectedDisplay = document.forms["display-select-dialog-form"].elements["display"].value;
|
||||
localStorage.setItem("display", selectedDisplay);
|
||||
setTimeout(() => displaySelectDialog.close(), 100);
|
||||
}
|
||||
|
||||
async function onShowSelectAudioSinkDialogClicked() {
|
||||
const audioSinks = await listAudioSinks();
|
||||
const currentAudioSink = localStorage.getItem("audio-sink");
|
||||
|
||||
const audioSinkSelectForm = document.querySelector("#audio-sink-select-dialog-form");
|
||||
audioSinkSelectForm.clearChildren();
|
||||
|
||||
for (let index in audioSinks) {
|
||||
const audioSink = audioSinks[index];
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "radio");
|
||||
input.setAttribute("name", "audiosink");
|
||||
input.setAttribute("id", `audiosink_${index}`);
|
||||
input.setAttribute("value", audioSink.description);
|
||||
input.addEventListener("change", onCloseSelectAudioSinkDialogClicked);
|
||||
if (audioSink.name == currentAudioSink) input.setAttribute("checked", "checked");
|
||||
audioSinkSelectForm.appendChild(input);
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.setAttribute("for", `audiosink_${index}`);
|
||||
label.textContent = audioSink.description;
|
||||
audioSinkSelectForm.appendChild(label);
|
||||
}
|
||||
|
||||
audioSinkSelectDialog.showModal();
|
||||
}
|
||||
|
||||
function onCloseSelectAudioSinkDialogClicked() {
|
||||
const selectedAudioSink = document.forms["audio-sink-select-dialog-form"].elements["audiosink"].value;
|
||||
localStorage.setItem("audio-sink", selectedAudioSink);
|
||||
changeAudioSink(selectedAudioSink);
|
||||
setTimeout(() => audioSinkSelectDialog.close(), 100);
|
||||
}
|
||||
|
||||
function onShowOpenNewVideoDialogClicked() {
|
||||
const link = document.querySelector("#open-new-video-dialog-link");
|
||||
link.value = "";
|
||||
openNewVideoDialog.showModal();
|
||||
}
|
||||
|
||||
function onOpenVideoButtonClicked() {
|
||||
const link = document.forms["open-new-video-dialog-form"].elements["link"].value;
|
||||
openVideo(link);
|
||||
openNewVideoDialog.close();
|
||||
}
|
||||
|
||||
function onOpenVideoInBackgroundButtonClicked() {
|
||||
const link = document.forms["open-new-video-dialog-form"].elements["link"].value;
|
||||
openVideoInBackground(link);
|
||||
openNewVideoDialog.close();
|
||||
}
|
||||
|
||||
function onCloseOpenNewVideoDialogClicked() {
|
||||
openNewVideoDialog.close();
|
||||
}
|
||||
|
||||
function askForCloseVideoConfirmation(videoID, videoTitle) {
|
||||
const videoTitleElement = document.querySelector("#close-video-dialog-title");
|
||||
videoTitleElement.textContent = videoTitle;
|
||||
closeVideoDialogID = videoID;
|
||||
closeVideoDialog.showModal();
|
||||
}
|
||||
|
||||
function onCloseCloseVideoDialogClicked() {
|
||||
closeVideoDialog.close();
|
||||
}
|
||||
|
||||
function onCloseVideoButtonClicked() {
|
||||
closeVideo(closeVideoDialogID)
|
||||
closeVideoDialog.close();
|
||||
}
|
||||
|
||||
function onCancelCloseVideoButtonClicked() {
|
||||
closeVideoDialog.close();
|
||||
}
|
||||
|
||||
function formatTimestamps(progress, duration) {
|
||||
const progressHours = Math.floor(progress / 3600);
|
||||
const progressRemaining = progress % 3600;
|
||||
const progressMinutes = Math.floor(progressRemaining / 60);
|
||||
const progressSeconds = progressRemaining % 60;
|
||||
|
||||
const durationHours = Math.floor(duration / 3600);
|
||||
const durationRemaining = duration % 3600;
|
||||
const durationMinutes = Math.floor(durationRemaining / 60);
|
||||
const durationSeconds = durationRemaining % 60;
|
||||
|
||||
if (durationHours > 0) {
|
||||
return {
|
||||
progress: `${progressHours.pad(2)}:${progressMinutes.pad(2)}:${progressSeconds.pad(2)}`,
|
||||
duration: `${durationHours.pad(2)}:${durationMinutes.pad(2)}:${durationSeconds.pad(2)}`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
progress: `${progressMinutes.pad(2)}:${progressSeconds.pad(2)}`,
|
||||
duration: `${durationMinutes.pad(2)}:${durationSeconds.pad(2)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function onPlayPauseButtonClicked() {
|
||||
if (currentVideoData.paused) {
|
||||
unpauseVideo(currentVideoData.id);
|
||||
} else {
|
||||
pauseVideo(currentVideoData.id);
|
||||
}
|
||||
}
|
||||
|
||||
function onReplayButtonClicked() {
|
||||
seekVideoRelative(currentVideoData.id, -10);
|
||||
}
|
||||
|
||||
function onForwardButtonClicked() {
|
||||
seekVideoRelative(currentVideoData.id, 10);
|
||||
}
|
||||
|
||||
function onSeekbarChanged() {
|
||||
seekVideo(currentVideoData.id, parseInt(seekbar.value));
|
||||
}
|
||||
|
||||
function onVolumeBarChanged() {
|
||||
setVolume(parseFloat(volumeBar.value));
|
||||
}
|
79
static/js/video.js
Normal file
79
static/js/video.js
Normal file
@ -0,0 +1,79 @@
|
||||
class Video {
|
||||
|
||||
constructor(videoData) {
|
||||
this.videoID = videoData.id;
|
||||
this.shown = false;
|
||||
|
||||
this.root = videoTemplate.content.cloneNode(true);
|
||||
this.item = this.root.querySelector(".video-item");
|
||||
this.closeButton = this.root.querySelector(".video-close");
|
||||
this.img = this.root.querySelector(".video-item .video-image");
|
||||
this.title = this.root.querySelector(".video-item .video-title");
|
||||
this.videoProgressPrimary = this.root.querySelector(".video-item .video-progress .primary");
|
||||
this.videoProgressSecondary = this.root.querySelector(".video-item .video-progress .secondary");
|
||||
|
||||
this.item.addEventListener("click", this.enable.bind(this));
|
||||
this.closeButton.addEventListener("click", this.close.bind(this));
|
||||
|
||||
this.update(videoData);
|
||||
}
|
||||
|
||||
enable() {
|
||||
const currentDisplay = localStorage.getItem("display");
|
||||
if (this.videoData.display != currentDisplay) {
|
||||
showVideo(this.videoID, currentDisplay);
|
||||
} else {
|
||||
hideVideo(this.videoID);
|
||||
}
|
||||
}
|
||||
|
||||
update(videoData) {
|
||||
if (videoData.id != this.videoID) {
|
||||
throw `tried to update video instance ${this.videoID} with data from video ${videoData.id}`;
|
||||
}
|
||||
|
||||
this.videoData = videoData;
|
||||
|
||||
const progressPercentage = videoData.progress / videoData.duration * 100;
|
||||
const bufferPercentage = videoData.buffered / videoData.duration * 100;
|
||||
|
||||
this.img.src = videoData.metadata.thumbnail;
|
||||
this.title.textContent = videoData.title;
|
||||
this.videoProgressPrimary.style.width = `${progressPercentage}%`;
|
||||
this.videoProgressSecondary.style.width = `${bufferPercentage}%`;
|
||||
|
||||
const currentDisplay = localStorage.getItem("display");
|
||||
if (videoData.display == currentDisplay) {
|
||||
this.img.classList.add("shown-on-display");
|
||||
} else {
|
||||
this.img.classList.remove("shown-on-display");
|
||||
}
|
||||
}
|
||||
|
||||
shownOnDisplay() {
|
||||
const currentDisplay = localStorage.getItem("display");
|
||||
return this.videoData.display == currentDisplay;
|
||||
}
|
||||
|
||||
show() {
|
||||
videoContainer.appendChild(this.item);
|
||||
this.shown = true;
|
||||
}
|
||||
|
||||
showAt(index) {
|
||||
videoContainer.insertChildAtIndex(this.item, index);
|
||||
this.shown = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
videoContainer.removeChild(this.item);
|
||||
this.shown = false;
|
||||
}
|
||||
|
||||
close(event) {
|
||||
console.log(event);
|
||||
event.stopPropagation();
|
||||
askForCloseVideoConfirmation(this.videoID, this.videoData.title);
|
||||
}
|
||||
|
||||
}
|
94
static/js/youtube.js
Normal file
94
static/js/youtube.js
Normal file
@ -0,0 +1,94 @@
|
||||
|
||||
async function listVideos() {
|
||||
const resp = await fetch("/api/video/list/");
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function listDisplays() {
|
||||
const resp = await fetch("/api/display/list/");
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
async function listAudioSinks() {
|
||||
const resp = await fetch("/api/audio/list/");
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
function changeAudioSink(sink) {
|
||||
fetch("/api/audio/change/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({sink}),
|
||||
});
|
||||
}
|
||||
|
||||
function showVideo(videoID, display) {
|
||||
fetch("/api/video/show/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID, display: display}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function hideVideo(videoID) {
|
||||
fetch("/api/video/hide/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function openVideo(link) {
|
||||
const display = localStorage.getItem("display");
|
||||
fetch("/api/video/open/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({link, display}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function openVideoInBackground(link) {
|
||||
fetch("/api/video/open/hide/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({link}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function closeVideo(videoID) {
|
||||
fetch("/api/video/close/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function pauseVideo(videoID) {
|
||||
fetch("/api/video/pause/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function unpauseVideo(videoID) {
|
||||
fetch("/api/video/play/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function seekVideo(videoID, second) {
|
||||
fetch("/api/video/seek/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID, second: second}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
function seekVideoRelative(videoID, second) {
|
||||
fetch("/api/video/seek/relative/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({id: videoID, second: second}),
|
||||
}).then(updateUI);
|
||||
}
|
||||
|
||||
|
||||
function setVolume(volume) {
|
||||
fetch("/api/video/volume/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({volume}),
|
||||
}).then(updateUI);
|
||||
}
|
BIN
yt-remote-control
Executable file
BIN
yt-remote-control
Executable file
Binary file not shown.
Loading…
Reference in New Issue
Block a user