diff options
| author | RblSb <msrblsb@gmail.com> | 2025-01-16 03:07:31 +0300 |
|---|---|---|
| committer | RblSb <msrblsb@gmail.com> | 2025-01-17 01:00:09 +0300 |
| commit | d9ca7beaa9494cf34590853901cf8be44e243775 (patch) | |
| tree | f09ce979460bdf28363a922298283dfee0c506fb /src/client | |
| parent | f84fdc40ba817b6a2d907484b1e1500197ceeafe (diff) | |
Cache on server feature
Server will download video from supported players and add as raw video to playlist (only youtube is supported for now).
Cache for YT player is available after installing optional dependencies, see readme. For cache size see `cacheStorageLimitGiB ` in config.
There is also minor ux improvement, latest checkbox states will be keeped in local storage now.
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/Buttons.hx | 34 | ||||
| -rw-r--r-- | src/client/ClientSettings.hx | 3 | ||||
| -rw-r--r-- | src/client/IPlayer.hx | 2 | ||||
| -rw-r--r-- | src/client/JsApi.hx | 5 | ||||
| -rw-r--r-- | src/client/Main.hx | 74 | ||||
| -rw-r--r-- | src/client/Player.hx | 43 | ||||
| -rw-r--r-- | src/client/players/Iframe.hx | 7 | ||||
| -rw-r--r-- | src/client/players/Raw.hx | 5 | ||||
| -rw-r--r-- | src/client/players/Youtube.hx | 55 |
9 files changed, 131 insertions, 97 deletions
diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx index c3581e2..f5f26e8 100644 --- a/src/client/Buttons.hx +++ b/src/client/Buttons.hx @@ -22,6 +22,12 @@ class Buttons { setSplitSize(settings.chatSize); initChatInput(main); + for (item in settings.checkboxes) { + if (item.checked == null) continue; + final checkbox:InputElement = ge('#${item.id}') ?? continue; + checkbox.checked = item.checked; + } + final passIcon = ge("#guestpass_icon"); passIcon.onclick = e -> { final icon = passIcon.firstElementChild; @@ -213,12 +219,15 @@ class Buttons { final mediaUrl:InputElement = cast ge("#mediaurl"); mediaUrl.oninput = () -> { - final value = mediaUrl.value; - final isRawSingleVideo = value != "" && main.isRawPlayerLink(value) - && main.isSingleVideoLink(value); - ge("#mediatitleblock").style.display = isRawSingleVideo ? "" : "none"; - ge("#subsurlblock").style.display = isRawSingleVideo ? "" : "none"; - ge("#voiceoverblock").style.display = value.length > 0 ? "" : "none"; + final url = mediaUrl.value; + final playerType = main.getLinkPlayerType(url); + final isSingle = main.isSingleVideoUrl(url); + final isSingleRawVideo = url != "" && playerType == RawType && isSingle; + ge("#mediatitleblock").style.display = isSingleRawVideo ? "" : "none"; + ge("#subsurlblock").style.display = isSingleRawVideo ? "" : "none"; + ge("#voiceoverblock").style.display = (url.length > 0 && isSingle) ? "" : "none"; + final showCache = isSingle && main.playersCacheSupport.contains(playerType); + ge("#cache-on-server").parentElement.style.display = showCache ? "" : "none"; final panel = ge("#addfromurl"); final oldH = panel.style.height; // save for animation panel.style.height = ""; // to calculate height from content @@ -482,6 +491,19 @@ class Buttons { if (Utils.isTouch()) chatline.blur(); return true; }); + final checkboxes:Array<InputElement> = [ + ge("#add-temp"), + ge("#cache-on-server"), + ]; + for (checkbox in checkboxes) { + checkbox.addEventListener("change", () -> { + final checked = checkbox.checked; + final item = settings.checkboxes.find(item -> item.id == checkbox.id); + settings.checkboxes.remove(item); + settings.checkboxes.push({id: checkbox.id, checked: checked}); + Settings.write(settings); + }); + } } static inline function getVisualViewport():Null<VisualViewport> { diff --git a/src/client/ClientSettings.hx b/src/client/ClientSettings.hx index a004213..cb5f99f 100644 --- a/src/client/ClientSettings.hx +++ b/src/client/ClientSettings.hx @@ -14,5 +14,6 @@ typedef ClientSettings = { latestLinks:Array<String>, latestSubs:Array<String>, hotkeysEnabled:Bool, - showHintList:Bool + showHintList:Bool, + checkboxes:Array<{id:String, checked:Null<Bool>}>, } diff --git a/src/client/IPlayer.hx b/src/client/IPlayer.hx index 032ac97..e8835d1 100644 --- a/src/client/IPlayer.hx +++ b/src/client/IPlayer.hx @@ -1,10 +1,12 @@ package client; +import Types.PlayerType; import Types.VideoData; import Types.VideoDataRequest; import Types.VideoItem; interface IPlayer { + function getPlayerType():PlayerType; function isSupportedLink(url:String):Bool; function getVideoData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void; function loadVideo(item:VideoItem):Void; diff --git a/src/client/JsApi.hx b/src/client/JsApi.hx index 1532231..abf9a6a 100644 --- a/src/client/JsApi.hx +++ b/src/client/JsApi.hx @@ -72,8 +72,9 @@ class JsApi { } @:expose - static function addVideoItem(url:String, atEnd:Bool, isTemp:Bool, ?callback:() -> Void):Void { - main.addVideo(url, atEnd, isTemp, callback); + static function addVideoItem(url:String, atEnd:Bool, isTemp:Bool, ?callback:() -> + Void, doCache = false):Void { + main.addVideo(url, atEnd, isTemp, doCache, callback); } @:expose diff --git a/src/client/Main.hx b/src/client/Main.hx index 248e205..72dbf8e 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -3,6 +3,7 @@ package client; import Client.ClientData; import Types.Config; import Types.Permission; +import Types.PlayerType; import Types.VideoData; import Types.VideoDataRequest; import Types.WsEvent; @@ -25,15 +26,16 @@ import js.html.WebSocket; using ClientTools; class Main { - static inline var SETTINGS_VERSION = 4; + static inline var SETTINGS_VERSION = 5; public final settings:ClientSettings; public var isSyncActive = true; public var forceSyncNextTick = false; - public var isVideoEnabled = true; + public var isVideoEnabled(default, null) = true; public final host:String; public var globalIp(default, null) = ""; - public var isPlaylistOpen = true; + public var isPlaylistOpen(default, null) = true; + public var playersCacheSupport(default, null):Array<PlayerType> = []; final clients:Array<Client> = []; var pageTitle = document.title; @@ -74,7 +76,8 @@ class Main { latestLinks: [], latestSubs: [], hotkeysEnabled: true, - showHintList: true + showHintList: true, + checkboxes: [], } Settings.init(defaults, settingsPatcher); settings = Settings.read(); @@ -104,7 +107,7 @@ class Main { if (!player.isVideoLoaded()) return; gotFirstPageInteraction = true; player.unmute(); - player.play(); + if (!hasLeader()) player.play(); document.removeEventListener("click", onFirstInteraction); } @@ -119,6 +122,9 @@ class Main { case 3: final data:ClientSettings = data; data.showHintList = true; + case 4: + final data:ClientSettings = data; + data.checkboxes = []; case SETTINGS_VERSION, _: throw 'skipped version $version'; } @@ -236,19 +242,19 @@ class Main { return personal.hasPermission(permission, config.permissions); } - final mask = ~/\${([0-9]+)-([0-9]+)}/g; + public final urlMask = ~/\${([0-9]+)-([0-9]+)}/g; function handleUrlMasks(links:Array<String>):Void { for (link in links) { - if (!mask.match(link)) continue; - final start = Std.parseInt(mask.matched(1)); - var end = Std.parseInt(mask.matched(2)); + if (!urlMask.match(link)) continue; + final start = Std.parseInt(urlMask.matched(1)); + var end = Std.parseInt(urlMask.matched(2)); if (Math.abs(start - end) > 100) continue; final step = end > start ? -1 : 1; final i = links.indexOf(link); links.remove(link); while (end != start + step) { - links.insert(i, mask.replace(link, '$end')); + links.insert(i, urlMask.replace(link, '$end')); end += step; } } @@ -257,8 +263,11 @@ class Main { function addVideoUrl(atEnd:Bool):Void { final mediaUrl:InputElement = cast ge("#mediaurl"); final subsUrl:InputElement = cast ge("#subsurl"); - final checkbox:InputElement = cast ge("#addfromurl").querySelector(".add-temp"); - final isTemp = checkbox.checked; + final checkboxTemp:InputElement = cast ge("#addfromurl .add-temp"); + final isTemp = checkboxTemp.checked; + final checkboxCache:InputElement = cast ge("#cache-on-server"); + final doCache = checkboxCache.checked + && checkboxCache.parentElement.style.display != "none"; final url = mediaUrl.value; final subs = subsUrl.value; if (url.length == 0) return; @@ -273,17 +282,15 @@ class Main { handleUrlMasks(links); // if videos added as next, we need to load them in reverse order if (!atEnd) sortItemsForQueueNext(links); - addVideoArray(links, atEnd, isTemp); + addVideoArray(links, atEnd, isTemp, doCache); } - public function isRawPlayerLink(url:String):Bool { - return player.isRawPlayerLink(url); + public function getLinkPlayerType(url:String):PlayerType { + return player.getLinkPlayerType(url); } - public function isSingleVideoLink(url:String):Bool { - if (~/, ?(https?)/g.match(url)) return false; - if (mask.match(url)) return false; - return true; + public function isSingleVideoUrl(url:String):Bool { + return player.isSingleVideoUrl(url); } public function sortItemsForQueueNext<T>(items:Array<T>):Void { @@ -295,13 +302,14 @@ class Main { if (first != null) items.unshift(first); } - function addVideoArray(links:Array<String>, atEnd:Bool, isTemp:Bool):Void { + function addVideoArray(links:Array<String>, atEnd:Bool, isTemp:Bool, doCache:Bool):Void { if (links.length == 0) return; final link = links.shift(); - addVideo(link, atEnd, isTemp, () -> addVideoArray(links, atEnd, isTemp)); + addVideo(link, atEnd, isTemp, doCache, () -> + addVideoArray(links, atEnd, isTemp, doCache)); } - public function addVideo(url:String, atEnd:Bool, isTemp:Bool, ?callback:() -> Void):Void { + public function addVideo(url:String, atEnd:Bool, isTemp:Bool, doCache:Bool, ?callback:() -> Void):Void { final protocol = Browser.location.protocol; if (url.startsWith("/")) { final host = Browser.location.hostname; @@ -330,9 +338,10 @@ class Main { author: personal.name, duration: data.duration, isTemp: isTemp, + doCache: doCache, subs: data.subs, voiceOverTrack: data.voiceOverTrack, - isIframe: data.isIframe == true + playerType: data.playerType }, atEnd: atEnd } @@ -349,7 +358,7 @@ class Main { final mediaTitle:InputElement = cast ge("#customembed-title"); final title = mediaTitle.value; mediaTitle.value = ""; - final checkbox:InputElement = cast ge("#customembed").querySelector(".add-temp"); + final checkbox:InputElement = cast ge("#customembed .add-temp"); final isTemp = checkbox.checked; final obj:VideoDataRequest = { url: iframe, @@ -372,7 +381,8 @@ class Main { author: personal.name, duration: data.duration, isTemp: isTemp, - isIframe: true + doCache: false, + playerType: IframeType }, atEnd: atEnd } @@ -504,12 +514,14 @@ class Main { case Pause: player.setPauseIndicator(false); + updateUserList(); if (isLeader()) return; player.pause(); player.setTime(data.pause.time); case Play: player.setPauseIndicator(true); + updateUserList(); if (isLeader()) return; final synchThreshold = settings.synchThreshold; final newTime = data.play.time; @@ -596,9 +608,6 @@ class Main { case Dump: Utils.saveFile("dump.json", ApplicationJson, data.dump.data); - - case GetYoutubeVideoInfo: - // handled by event listeners like `JsApi.once` } } @@ -609,6 +618,7 @@ class Main { Settings.write(settings); globalIp = connected.globalIp; + playersCacheSupport = connected.playersCacheSupport; setConfig(connected.config); if (connected.isUnknownClient) { updateClients(connected.clients); @@ -906,7 +916,8 @@ class Main { final list = new StringBuf(); for (client in clients) { list.add('<div class="userlist_item">'); - if (client.isLeader) list.add('<ion-icon name="play"></ion-icon>'); + final iconName = player.isPaused() ? "pause" : "play"; + if (client.isLeader) list.add('<ion-icon name="$iconName"></ion-icon>'); var klass = client.isBanned ? "userlist_banned" : ""; if (client.isAdmin) klass += " userlist_owner"; list.add('<span class="$klass">${client.name}</span></div>'); @@ -1232,7 +1243,8 @@ class Main { return ~/([.*+?^${}()|[\]\\])/g.replace(regex, "\\$1"); } - public static inline function ge(id:String):Element { - return document.querySelector(id); + @:generic + public static inline function ge<T:Element>(id:String):T { + return cast document.querySelector(id); } } diff --git a/src/client/Player.hx b/src/client/Player.hx index f40c34c..70efcf9 100644 --- a/src/client/Player.hx +++ b/src/client/Player.hx @@ -1,5 +1,6 @@ package client; +import Types.PlayerType; import Types.VideoData; import Types.VideoDataRequest; import Types.VideoItem; @@ -41,7 +42,7 @@ class Player { streamable = new Streamable(main, this); players = [ youtube, - streamable + streamable, ]; iframePlayer = new Iframe(main, this); rawPlayer = new Raw(main, this); @@ -118,26 +119,40 @@ class Player { var player = players.find(player -> player.isSupportedLink(req.url)); player ??= rawPlayer; player.getVideoData(req, data -> { + data.playerType ??= player.getPlayerType(); final voiceOverTrack = voiceOverInput.value.trim(); - data.voiceOverTrack = voiceOverTrack; voiceOverInput.value = ""; + data.voiceOverTrack ??= voiceOverTrack; callback(data); }); } - public function isRawPlayerLink(url:String):Bool { - if (streamable.isSupportedLink(url)) return true; - return !players.exists(player -> player.isSupportedLink(url)); + public function getLinkPlayerType(url:String):PlayerType { + final player = players.find(player -> player.isSupportedLink(url)); + if (player == null) return rawPlayer.getPlayerType(); + return player.getPlayerType(); + } + + public function isSingleVideoUrl(url:String):Bool { + if (youtube.isSupportedLink(url)) { + if (youtube.isPlaylistUrl(url)) return false; + } + if (~/, ?(https?)/g.match(url)) return false; + if (main.urlMask.match(url)) return false; + return true; } public function getIframeData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void { - iframePlayer.getVideoData(data, callback); + iframePlayer.getVideoData(data, data -> { + data.playerType = IframeType; + callback(data); + }); } public function setVideo(i:Int):Void { if (!main.isSyncActive) return; final item = videoList.getItem(i); - setSupportedPlayer(item.url, item.isIframe); + setSupportedPlayer(item.url, item.playerType); removeActiveLabel(videoList.pos); videoList.setPos(i); @@ -190,17 +205,17 @@ class Player { needsVolumeReset = true; } - function setSupportedPlayer(url:String, isIframe:Bool):Void { + function setSupportedPlayer(url:String, playerType:PlayerType):Void { final currentPlayer = players.find(p -> p.isSupportedLink(url)); if (currentPlayer != null) setPlayer(currentPlayer); - else if (isIframe) setPlayer(iframePlayer); + else if (playerType == IframeType) setPlayer(iframePlayer); else setPlayer(rawPlayer); } public function changeVideoSrc(url:String):Void { if (!main.isVideoEnabled) return; final item:VideoItem = videoList.currentItem ?? return; - setSupportedPlayer(url, item.isIframe); + setSupportedPlayer(url, item.playerType); player.loadVideo(item.withUrl(url)); } @@ -247,10 +262,6 @@ class Player { final item = videoList.currentItem ?? return; // do not send pause if video is ended if (getTime() >= item.duration - 0.01) return; - // youtube raw fallback has around one second difference from rounded youtube duration - if (player == rawPlayer && youtube.isSupportedLink(item.url)) { - if (getTime() >= item.duration - 1) return; - } final hasAutoPause = main.hasLeaderOnPauseRequest() && videoList.length > 0 && getTime() > 1 @@ -316,7 +327,7 @@ class Player { public function addVideoItem(item:VideoItem, atEnd:Bool):Void { final url = item.url.htmlEscape(true); - final duration = item.isIframe ? "" : duration(item.duration); + final duration = item.playerType == IframeType ? "" : duration(item.duration); final itemEl = Utils.nodeFromString( '<li class="queue_entry info" title="${Lang.get("addedBy")}: ${item.author}"> <header> @@ -449,7 +460,7 @@ class Player { function totalDuration():String { var time = 0.0; for (item in videoList.getItems()) { - if (item.isIframe) continue; + if (item.playerType == IframeType) continue; time += item.duration; } return duration(time); diff --git a/src/client/players/Iframe.hx b/src/client/players/Iframe.hx index 56cf319..8a2e06e 100644 --- a/src/client/players/Iframe.hx +++ b/src/client/players/Iframe.hx @@ -1,5 +1,6 @@ package client.players; +import Types.PlayerType; import Types.VideoData; import Types.VideoDataRequest; import Types.VideoItem; @@ -18,13 +19,17 @@ class Iframe implements IPlayer { this.player = player; } + public function getPlayerType():PlayerType { + return IframeType; + } + public function isSupportedLink(url:String):Bool { return true; } public function getVideoData(data:VideoDataRequest, callback:(data:VideoData) -> Void):Void { final iframe = document.createDivElement(); - iframe.innerHTML = data.url; + iframe.innerHTML = data.url.trim(); if (isValidIframe(iframe)) { callback({duration: 99 * 60 * 60}); } else { diff --git a/src/client/players/Raw.hx b/src/client/players/Raw.hx index 5c5b7a4..7e5e50c 100644 --- a/src/client/players/Raw.hx +++ b/src/client/players/Raw.hx @@ -1,5 +1,6 @@ package client.players; +import Types.PlayerType; import Types.VideoData; import Types.VideoDataRequest; import Types.VideoItem; @@ -30,6 +31,10 @@ class Raw implements IPlayer { this.player = player; } + public function getPlayerType():PlayerType { + return RawType; + } + public function isSupportedLink(url:String):Bool { return true; } diff --git a/src/client/players/Youtube.hx b/src/client/players/Youtube.hx index 3ad9030..5a8921a 100644 --- a/src/client/players/Youtube.hx +++ b/src/client/players/Youtube.hx @@ -1,5 +1,6 @@ package client.players; +import Types.PlayerType; import Types.VideoData; import Types.VideoDataRequest; import Types.VideoItem; @@ -23,7 +24,6 @@ class Youtube implements IPlayer { var apiKey:String; var video:Element; var youtube:YoutubePlayer; - var tempYoutube:YoutubePlayer; var isLoaded = false; public function new(main:Main, player:Player) { @@ -31,6 +31,10 @@ class Youtube implements IPlayer { this.player = player; } + public function getPlayerType():PlayerType { + return YoutubeType; + } + public function isSupportedLink(url:String):Bool { return extractVideoId(url) != "" || extractPlaylistId(url) != ""; } @@ -43,6 +47,10 @@ class Youtube implements IPlayer { return YoutubeUtils.extractPlaylistId(url); } + public function isPlaylistUrl(url:String):Bool { + return extractVideoId(url) == "" && extractPlaylistId(url) != ""; + } + final matchHours = ~/([0-9]+)H/; final matchMinutes = ~/([0-9]+)M/; final matchSeconds = ~/([0-9]+)S/; @@ -93,7 +101,7 @@ class Youtube implements IPlayer { url: '<iframe src="https://www.youtube.com/embed/$id?autoplay=1$mute" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>', - isIframe: true + playerType: IframeType }); continue; } @@ -168,6 +176,7 @@ class Youtube implements IPlayer { final video = document.createDivElement(); video.id = "temp-videoplayer"; Utils.prepend(playerEl, video); + var tempYoutube:YoutubePlayer = null; tempYoutube = new YoutubePlayer(video.id, { videoId: extractVideoId(url), playerVars: { @@ -181,12 +190,14 @@ class Youtube implements IPlayer { callback({ duration: tempYoutube.getDuration() }); + tempYoutube.destroy(); }, onError: e -> { // TODO message error codes trace('Error ${e.data}'); if (playerEl.contains(video)) playerEl.removeChild(video); callback({duration: 0}); + tempYoutube.destroy(); } } }); @@ -243,49 +254,13 @@ class Youtube implements IPlayer { onError: e -> { // TODO message error codes trace('Error ${e.data}'); - final item = player.getCurrentItem() ?? return; - rawSourceFallback(item.url); + // final item = player.getCurrentItem() ?? return; + // rawSourceFallback(item.url); } } }); } - function rawSourceFallback(url:String):Void { - JsApi.once(GetYoutubeVideoInfo, event -> { - final data = event.getYoutubeVideoInfo; - final info = data.response; - final format = getBestStreamFormat(info) ?? { - trace("format not found in response info:"); - trace(info); - return; - }; - player.changeVideoSrc(format.url); - }); - main.send({ - type: GetYoutubeVideoInfo, - getYoutubeVideoInfo: { - url: url - } - }); - } - - function getBestStreamFormat(info:YouTubeVideoInfo):Null<YoutubeVideoFormat> { - info.formats ??= []; - info.adaptiveFormats ??= []; - final formats = info.adaptiveFormats.concat(info.formats); - trace(formats); - final qPriority = [1080, 720, 480, 360, 240]; - for (q in qPriority) { - final quality = '${q}p'; - for (format in formats) { - if (format.audioQuality == null) continue; // no sound - if (format.width == null) continue; // no video - if (format.qualityLabel == quality) return format; - } - } - return null; - } - public function removeVideo():Void { if (video == null) return; isLoaded = false; |
