Browse Source

merged from zedshaw.games work

dev
Zed A. Shaw 1 week ago
parent
commit
fb9c1ca3f8
  1. 308
      client/components/HLSVideo.svelte
  2. 5
      client/components/IsVisible.svelte
  3. 15
      client/components/Modal.svelte
  4. 16
      client/components/ShareButton.svelte
  5. 75
      scripts/loader.js
  6. 2
      scripts/templates/secrets/config.json
  7. 2
      static/djenterator/client.svelte
  8. 5
      static/djenterator/migration.js
  9. 17
      static/global.css
  10. 4
      tests/ui/login.js

308
client/components/HLSVideo.svelte

@ -0,0 +1,308 @@
<script>
import { onMount, onDestroy } from 'svelte';
import Spinner from "$/client/components/Spinner.svelte";
import Countdown from "$/client/components/Countdown.svelte";
import Icon from '$/client/components/Icon.svelte';
import FSM from '$/client/fsm.js';
import assert from "$/client/assert.js";
export let aspect_ratio = "16/9";
export let source = "";
export let content_type = "application/x-mpegURL";
export let poster = "";
export let preload = "";
export let playsinline = false;
export let poll_tick = 10000;
export let poll_limit = 10;
export let background_color = "var(--color-accent)";
export let starts_on = false;
export let spinner = true;
export let controls = true;
export let muted = false;
export let autoplay = false;
let video_config = {video_ready: false};
let poll_count = 0;
let poll_timer;
let fsm;
let hls;
export let video_tag;
const supports_hls_native = () => {
let video = document.getElementById("video-player");
return video.canPlayType('application/vnd.apple.mpegURL') != "" || video.canPlayType('application/x-mpegURL') != "";
}
const load_hlsjs_script = () => {
let script = document.createElement("script");
script.setAttribute('src', "/js/hls.js");
document.body.appendChild(script);
script.onload = async () => {
// NOTE: might have to revisit this as it might not reliably keep hls.js loaded
if(!video_config.hlsjs_configured) {
video_config.hlsjs_configured = true;
await fsm.do("hlsjs_loaded");
}
}
}
/* You pass it the state it should return on success, will
* return HLS_ERROR if there's a problem.
*/
const recover_hls = (state) => {
console.log("HLS error", video_config.hls_error_type, "in state", state);
switch(video_config.hls_error_type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
return state;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
return state;
default:
console.log("HLS error is unknown type");
hls.destroy();
return "HLS_ERROR";
}
}
const check_video = async () => {
let test = await fetch(source);
poll_count += poll_count;
if(test.status == 200) {
await fsm.do("video_ready");
clearInterval(poll_timer);
} else if(poll_count > poll_limit) {
clearInterval(poll_timer);
await fsm.do("poll_limit");
}
return test.status == 200;
}
const poll_video_ready = async () => {
if(!await check_video()) {
poll_timer = setInterval(check_video, poll_tick);
}
}
const start_hlsjs = () => {
assert(source.includes(".m3u8"), `HLS is loaded but source doesn't have m3u8 "${source}"`);
assert(Hls !== undefined, "hlsjs_configured is true but no Hls object?");
assert(Hls.isSupported(), "HLS trying to run but Hls.isSupported() claims false.");
let video = document.getElementById("video-player");
hls = new Hls();
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
console.log("HLS MEDIA_ATTACHED event start.");
hls.loadSource(source);
hls.on(Hls.Events.MANIFEST_PARSED,
async (event, details) => {
console.log("HLS MANIFEST_PARSED event start.");
await fsm.do("video_ready")
});
hls.on(Hls.Events.ERROR, async (event, error) => {
console.log("HLS ERROR event start.", error.type);
video_config.hls_error_type = error.type;
await fsm.do("hls_error");
});
});
}
const detect_video = () => {
if(!video_config.hls_source || video_config.hls_natively) {
// can just use HTML5 video directly
return ["POLLING_VIDEO", poll_video_ready];
} else if(video_config.hlsjs_configured) {
// it's an HLS video but the browser doesn't support it
// the hls.js script tag is already loaded so just run it
return ["HLSJS_READY", start_hlsjs];
} else {
// still an HLS video, but need to load hls.js
// need to load the hls.js script tag for this browser
return ["LOADING_HLSJS", load_hlsjs_script];
}
}
class VideoEvents {
async mount(state) {
switch(state) {
case "START":
video_config.hls_source = source.includes(".m3u8");
video_config.hls_natively = supports_hls_native();
if(starts_on && !video_config.countdown_done) {
return "COUNTING_DOWN";
} else {
return detect_video();
}
default:
return "ERROR";
}
}
async hlsjs_loaded(state) {
switch(state) {
case "LOADING_HLSJS":
start_hlsjs();
return "HLSJS_READY";
default:
return "ERROR";
}
}
async video_ready(state) {
switch(state) {
case "VIDEO_READY":
case "POLLING_VIDEO":
case "HLSJS_READY":
case "HLS_RECOVERING":
case "HLS_POLLING":
video_config.video_ready = true;
return "VIDEO_READY";
default:
return "ERROR";
}
}
async hls_error(state) {
// attempt to just resolve the error and stay in the same state
// TODO: this might be wrong, need to confirm it. Previously I
// return "HLS_RECOVERING" instead of the state that was given
switch(state) {
case "HLS_RECOVERING":
return recover_hls(state);
case "VIDEO_READY":
return recover_hls(state);
default:
console.log("HLS error on state", state, "Not handled");
return "ERROR";
}
}
countdown_done(state) {
video_config.countdown_done = true;
switch(state) {
case "COUNTING_DOWN":
return detect_video();
default:
return "ERROR";
}
}
poll_limit(state) {
switch(state) {
case "POLLING_VIDEO":
return "POLL_LIMIT";
default:
return "ERROR";
}
}
clean_video() {
// this does seem to help with ram on Safari but only just
video_tag.pause();
video_tag.src = "";
video_tag.load();
}
destroy(state) {
console.log("FSM: Destroying video on state", state);
if(hls) hls.destroy();
if(video_tag) this.clean_video();
clearInterval(poll_timer)
video_config.video_ready = false;
video_config.hls_error_type = undefined;
return "END";
}
}
onDestroy(async () => await fsm.do("destroy"));
onMount(async () => {
await fsm.do("mount");
});
fsm = new FSM(video_config, new VideoEvents());
fsm.onTransition(fsm => {
video_config.state = fsm.state
});
</script>
<style>
video-player video.not-ready {
filter: blur(4px);
}
video-player loading-panel {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
video-player loading-panel:hover {
box-shadow: var(--box-shadow) var(--color-shadow);
}
video-player {
overflow: hidden;
}
video-player loading-panel h1,
video-player loading-panel h2 {
color: var(--color-bg-secondary);
font-size: 7vw;
margin-top: 0;
margin-bottom: 0;
text-shadow: 8px 8px 5px var(--color-overlay-shadow);
}
video-player loading-panel h2 {
font-size: 4vw;
}
</style>
<!-- Safari leaks the video content if you use a <source> tag. Future versions of this will
try to detect Safari and adjust accordingly, but for now this is the only thing that works.
-->
<video-player class="stacked" style="background: { background_color };">
<video bind:this={ video_tag }
playsinline={ playsinline }
src={ source }
type={ content_type }
class:not-ready={video_config.state !== "VIDEO_READY"}
class="layer" style="--aspect-ratio: { aspect_ratio };"
poster={poster}
controls={ controls && video_config.video_ready }
id="video-player"
preload={ preload }
autoplay={ autoplay }
muted={ muted }
>
</video>
{#if video_config.state !== "VIDEO_READY"}
<loading-panel class="layer top">
{#if video_config.state === "COUNTING_DOWN" }
<Countdown starts_on={ starts_on } on:done={ () => fsm.do("countdown_done") } />
{:else if video_config.state === "POLL_LIMIT" }
<Icon name="alert-triangle" size="7vw" color="var(--color-bg-secondary)" shadow={ true } />
<h1>Video Broken</h1>
<h2>Come back in a few minutes...</h2>
{:else if spinner}
<Spinner color="var(--color-bg)" size="128" />
{:else}
<img src={ poster } />
{/if}
</loading-panel>
{/if}
</video-player>

5
client/components/IsVisible.svelte

@ -7,21 +7,16 @@
const dispatch = createEventDispatcher();
const show = (entry) => {
console.log("show!", entry);
visible = true;
dispatch("visible", entry);
}
const hide = (entry) => {
console.log("hide!", entry);
visible = false;
dispatch("hidden", entry);
}
</script>
<style>
</style>
<div use:visibility={ options } on:visible={ show } on:hidden={ hide }>
{#if visible}
<slot name="visible">

15
client/components/Modal.svelte

@ -1,10 +1,13 @@
<script>
import { createEventDispatcher } from 'svelte';
export let active=true;
export let full_screen = false;
const dispatch = createEventDispatcher();
// this doesn't seem to work on chrome
const escape_pressed = (event) => {
console.log("Key pressed", event.key);
if(event.key === 'Escape' || event.which === 27) {
dispatch('close');
}
@ -13,7 +16,7 @@
<style>
modal {
position: absolute;
position: fixed;
top: 0;
bottom: 0;
left: 0;
@ -32,12 +35,20 @@
box-shadow: var(--box-shadow);
background: var(--color-bg);
}
modal.full-screen content {
margin-top: 0px;
padding: 0px;
border: none;
box-shadow: none;
background: var(--color-bg);
}
</style>
<svelte:window on:keypress={ escape_pressed } />
{#if active}
<modal>
<modal class:full-screen={ full_screen }>
<content>
<slot></slot>
</content>

16
client/components/ShareButton.svelte

@ -0,0 +1,16 @@
<script>
import Icon from "$/client/components/Icon.svelte";
import { base_host } from "$/client/config.js";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let url;
export let color="var(--color-accent)";
const copy_share_link = () => {
navigator.clipboard.writeText(`${base_host}${url}`);
dispatch("click");
}
</script>
<span on:click={ () => copy_share_link() }><Icon name="share" color={ color } /></span>

75
scripts/loader.js

@ -2,15 +2,12 @@ import path from "path"
import fs from "fs"
import { execSync as exec } from "child_process"
import glob from "fast-glob";
import { Media } from "../lib/models.js";
import { knex } from "../lib/ormish.js";
const input = process.argv[2];
const force = process.argv[3] === "-f";
const json_path = (fname) => {
const out = path.parse(fname);
return path.join(out.dir, `${out.name}.json`);
}
const screenshot = (video_file) => {
// parse the filename and extract the date
let pinfo = path.parse(video_file);
@ -18,50 +15,60 @@ const screenshot = (video_file) => {
const screen_name = `${target_dir}/${target_name}.screen.jpg`;
console.log("SCREEN", screen_name);
const stdout = exec(`ffmpeg -y -i "${video_file}" -vf "select=eq(n\\,0)" -q:v 10 "${screen_name}"`);
return screen_name;
}
const video_meta = (fname, force=false) => {
const jf = json_path(fname);
if(force || !fs.existsSync(jf)) {
const stdout = exec(`ffprobe -v quiet -print_format json -show_format -show_streams "${fname}"`);
const stdout = exec(`ffprobe -v quiet -print_format json -show_format -show_streams "${fname}"`);
const meta = JSON.parse(stdout);
// find the screenshots
meta.poster = screenshot(meta.format.filename);
meta.format.ctime = fs.statSync(fname).ctime;
const meta = JSON.parse(stdout);
// find the screenshots
meta.poster = screenshot(meta.format.filename);
meta.format.ctime = fs.statSync(fname).ctime;
return meta;
// the filename has the date recorded which is better than ctime
const extractor = /.*Replay_([0-9-]+)_([0-9-]+)\.*/
const match = fname.match(extractor);
if(match) {
// kind of ugly but works for now
meta.format.recorded_at = new Date(`${match[1]} ${match[2].replace(/\-/g, ':')}`);
} else {
return JSON.parse(fs.readFileSync(jf));
console.error("BAD FILE! match for", fname, "is", match);
}
return meta;
}
const video_files = glob.sync(input.replace(/\\/g,"/"));
const videos = video_files.map(f => video_meta(f, force));
const index = [];
videos.forEach((v, i) => {
fs.writeFileSync(json_path(v.format.filename), JSON.stringify(v, null, 4));
const videos = video_files.map(f => video_meta(f, force)).sort((a, b) => new Date(b.format.ctime) - new Date(a.format.ctime));
// fake the index data for now, add a . in front to make the stupid URL relative
index.push({
"id": i,
"title": "No Title",
"views": 0,
"description": `No Description`,
"created_at": v.format.ctime,
"updated_at": v.format.ctime,
for(let v of videos) {
const stream = v.streams[0];
const data = {
// "title": "", // don't overwrite these anymore
// "description": "", // stop removing the description too
// "views": 0,
"created_at": v.format.recorded_at, // recorded at comes from the filename
"updated_at": v.format.ctime, // ctime works better as the update time
"src": v.format.filename.slice(1),
"preload": true,
"torrent_url": `/torrents/${v.format.filename.slice(1)}.torrent`,
"codec_name": stream.codec_name,
"width": Number.parseInt(stream.width, 10),
"height": Number.parseInt(stream.height, 10),
"duration": Number.parseFloat(stream.duration),
"aspect_ratio": stream.display_aspect_ratio.replace(":", "/"),
"preload": "auto",
// "state": "new",
"torrent_url": `/torrents${v.format.filename.slice(1)}.torrent`,
"poster": v.poster.slice(1),
});
});
}
// the db doesn't return the id unless it's an insert
await Media.upsert(data, "src");
}
knex.destroy();
fs.writeFileSync("./static/clips/index.json", JSON.stringify(index, null, 4));
console.log("Database updated with recent videos. Edit them in the UI then rerun ./scripts/clips_index.js to generate ./static/clips/index.json");

2
scripts/templates/secrets/config.json

@ -1,6 +1,6 @@
{
"paypal_private": {
"email": "payments-facilitator@shavian-publishing.com",
"email": "yourpaypalemail@yoursite.com",
"client_id": "yourpaypal PRIVATE client id",
"secret": "your paypal PRIVATE secret",
"disabled": true

2
static/djenterator/client.svelte

@ -3,7 +3,7 @@
import Validator from 'Validator';
import { onMount } from 'svelte';
import Layout from '$/client/Layout.svelte';
import api from "../api.js";
import api from "$/client/api.js";
api.mock({
"/api<%- api_url %>": {

5
static/djenterator/migration.js

@ -8,8 +8,11 @@ exports.up = async (knex) => {
table.integer('poster_id').notNullable();
table.foreign('poster_id').references('id').inTable('user');
});
// because this is an async it'll automatically be a promise for knex
// add any other tables here with an await, no need to return
};
exports.down = async (knex) => {
return await knex.schema.dropTable('<%= table %>');
await knex.schema.dropTable('<%= table %>');
};

17
static/global.css

@ -25,6 +25,20 @@ body {
line-height: var(--line-height);
margin: 0;
overflow-x: hidden;
overflow-y: overlay;
text-rendering: optimizeSpeed;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background: hsla(0, 0%, 30%, 0.3);
}
::-webkit-scrollbar-track-piece {
background: hsla(0, 0%, 50%, 0.2);
}
header {
@ -48,7 +62,7 @@ header.fixed {
top: 0;
right: 0;
left: 0;
z-index: 1000;
z-index: 90;
}
header.fixed + main {
@ -1304,6 +1318,7 @@ main.fullscreen {
height: 100vh !important;
max-height: 100vh !important;
min-height: 100vh !important;
max-width: unset;
}
main.fullwidth {

4
tests/ui/login.js

@ -47,10 +47,10 @@ test('standalone login page works', async (t) => {
await p.fill("#password", user.password);
await p.click(tid('login-button'));
await sleep(1000);
await wait(p, tid('home-page'));
await wait(p, tid('clips-home-page'));
// confirm home page and logout-link are visible
await expect(t, p, tid('home-page'));
await expect(t, p, tid('clips-home-page'));
await expect(t, p, tid('logout-link'));
// logout and see login to chat

Loading…
Cancel
Save