Browse Source

Forgot I had to add in all the modified pages from the base project.

dev
Zed A. Shaw 1 month ago
parent
commit
f101c12b95
  1. 9
      .gitignore
  2. 16
      client/Footer.svelte
  3. 34
      client/Header.svelte
  4. 44
      client/Layout.svelte
  5. 35
      client/config.js
  6. 44
      client/routes.js
  7. 19
      rendered/Header.svelte
  8. 33
      rendered/Layout.svelte
  9. 299
      rendered/pages/index.svelte
  10. 51
      rendered/pages/test_large_card.svelte
  11. 64
      rendered/pages/test_video_card.svelte
  12. 17
      rendered/pages/video_container.svelte
  13. 56
      scripts/loader.js
  14. 193
      temp_data.js

9
.gitignore

@ -10,14 +10,5 @@ debug/
static/thumbs
static/videos
secrets/*
client/Footer.svelte
client/Header.svelte
client/Layout.svelte
client/pages/Home.svelte
rendered/Header.svelte
rendered/Layout.svelte
rendered/pages/index.svelte
client/routes.js
client/config.js
coverage/
.coverage

16
client/Footer.svelte

@ -0,0 +1,16 @@
<footer>
<aside>
<h4>Contact</h4>
<span>
You can always message me on Twitter at <a href="https://twitter.com/lzsthw">@lzsthw</a>.
</span>
</aside>
<aside>
<h4>About</h4>
<span>This project is a work in progress project for <a href="https://learnjsthehardway.com">Learn JavaScript the Hard Way</a>. It is <b>Copyright &copy; 2021 by Zed A. Shaw.</b></span>
</aside>
<aside>
<h4>Code</h4>
<span>You can look at the code in the <a href="https://git.learnjsthehardway.com/zedshaw/zedshaw.games">zedshaw.games git repository.</a> This is <b>not</b> open source software and you have no license to run it.</span>
</aside>
</footer>

34
client/Header.svelte

@ -0,0 +1,34 @@
<script>
import { logout_user } from '$/client/api';
import Icon from '$/client/components/Icon.svelte';
import Darkmode from '$/client/components/Darkmode.svelte';
import {link} from 'svelte-spa-router';
import { user } from "$/client/stores.js";
import { register_enabled } from "$/client/config.js";
</script>
<header>
<nav>
{#if $user.authenticated}
<a href="/" use:link><Icon name="home" size="48" /></a>
<ul>
<li><a on:click|preventDefault={ logout_user } data-testid="logout-link">Logout</a></li>
<li><a data-testid="user-profile-button" href="/profile/" use:link><Icon name="settings" /></a></li>
{#if $user.admin }
<li><a href="/admin/table/" use:link><Icon name="database" /></a></li>
{/if}
<li><Darkmode /></li>
</ul>
{:else}
<!-- don't use:link because we want them to go to the landing page if they aren't logged in. -->
<a href="/"><Icon name="home" size="48" /></a>
<ul>
{#if register_enabled}
<li><a href="/register/" use:link>Register</a></li>
<li><a href="/login/" use:link>Login</a></li>
{/if}
<li><Darkmode /></li>
</ul>
{/if}
</nav>
</header>

44
client/Layout.svelte

@ -0,0 +1,44 @@
<script>
import LoggedIn from '$/client/components/LoggedIn.svelte';
import Spinner from '$/client/components/Spinner.svelte';
import Footer from '$/client/Footer.svelte';
import Header from '$/client/Header.svelte';
export let footer = true;
export let header = true;
export let authenticated = false;
export let testid = "page";
export let centered = false;
export let fullscreen = false;
</script>
{#if authenticated}
{#if header}
<Header />
{/if}
<LoggedIn redirect="/login" show_required_page={ false }>
<main class:fullscreen={fullscreen} class:centered={ centered } slot="yes" data-testid={ testid }>
<slot></slot>
</main>
<main class:fullscreen={fullscreen} class:centered={ centered } slot="no" data-testid={ testid }>
<Spinner />
</main>
</LoggedIn>
{#if footer}
<Footer />
{/if}
{:else}
{#if header}
<Header />
{/if}
<main class:fullscreen={fullscreen} class:centered={ centered } data-testid={ testid }>
<slot></slot>
</main>
{#if footer}
<Footer />
{/if}
{/if}

35
client/config.js

@ -0,0 +1,35 @@
/* These are configuration options which can be public.
* WARNING: Do not put any sensitive keys in these files. They *will* be
* seen by the end user if you do. Only things an unauthenticated user
* is allowed to see. Anything else should go in lib/config.js and
* *never* import that file in any client/* files.
*/
export const fake_payments = true;
export const webtorrent = {
use_dht: false,
private: true
}
export const product = {
price: 20,
currency: 'USD',
description: 'Super Product'
}
export const btcpay_url = 'https://yourpayserver.com/modal/btcpay.js';
export const course_id = 1;
export const base_host = 'http://localhost:5001';
export const support_email = "help@yourcompany.com";
export const paypal_public = {
disabled: true,
client_id: "your paypal PUBLIC client ID.",
}
export const darkmode_default = "dark";
export const register_enabled = true;

44
client/routes.js

@ -0,0 +1,44 @@
import LiveStream from './pages/LiveStream.svelte';
import ReplayStream from './pages/ReplayStream.svelte';
import Register from './pages/Register.svelte';
import Login from './pages/Login.svelte';
import AdminIndex from './pages/admin/index.svelte';
import AdminCreate from './pages/admin/Create.svelte';
import AdminTable from './pages/admin/Table.svelte';
import AdminTests from './pages/admin/Tests.svelte';
import AdminReadUpdate from './pages/admin/ReadUpdate.svelte';
import Series from './pages/Series.svelte';
import UserProfile from './pages/UserProfile.svelte';
import ResetPassword from './pages/ResetPassword.svelte';
import Unsubscribe from './pages/Unsubscribe.svelte';
import NotFound from './pages/NotFound.svelte';
/* #if process.env.DANGER_ADMIN
import Components from './bando/Components.svelte';
import EmailConfig from './pages/admin/EmailConfig.svelte';
// #endif */
import Purchase from "./pages/Purchase.svelte";
import TOS from "./pages/TOS.svelte";
export default {
'/watch/': LiveStream,
'/series/:series_id/': Series,
'/series/:series_id/stream/:stream_id/': ReplayStream,
'/register/': Register,
'/login/': Login,
'/forgot/': ResetPassword,
'/profile/': UserProfile,
'/tos/': TOS,
'/purchase/': Purchase,
'/email/unsubscribe/:unsubkey/': Unsubscribe,
'/admin/table/create/:table/': AdminCreate,
'/admin/table/:table/': AdminTable,
'/admin/table/:table/:row_id/': AdminReadUpdate,
'/admin/table/': AdminIndex,
'/admin/tests/': AdminTests,
/* #if process.env.DANGER_ADMIN
'/admin/email/': EmailConfig,
'/bando/components/:name?': Components,
// #endif */
'/': LiveStream,
'*': NotFound,
}

19
rendered/Header.svelte

@ -0,0 +1,19 @@
<script>
import Icon from '$/client/components/Icon.svelte';
import Darkmode from '$/client/components/Darkmode.svelte';
import { Hydrate } from '@jamcart/7ty/components';
import { register_enabled } from "$/client/config.js";
</script>
<header>
<nav>
<a href="/"><Icon name="home" size="48" /></a>
<ul>
{#if register_enabled}
<li><a href="/client/#/register/">Register</a></li>
<li><a href="/client/#/login/">Login</a></li>
{/if}
<li><Hydrate component={ Darkmode } /></li>
</ul>
</nav>
</header>

33
rendered/Layout.svelte

@ -0,0 +1,33 @@
<style>
img.logo {
width: 48px;
}
</style>
<script>
import Footer from '$/client/Footer.svelte';
import Header from './Header.svelte';
export let fullscreen = false;
export let centered = false;
export let header = true;
export let footer = true;
export let testid;
export let bare = false;
</script>
{#if header}
<Header />
{/if}
{#if !bare}
<main class:fullscreen={fullscreen} class:centered={ centered } data-testid={ testid }>
<slot></slot>
</main>
{:else}
<slot></slot>
{/if}
{#if footer}
<Footer />
{/if}

299
rendered/pages/index.svelte

@ -0,0 +1,299 @@
<script context="module">
import { series_data } from '../../temp_data.js';
export const getData = ({ slug, id }) => {
const [status, data] = [200, series_data];
if(status == 200) {
return { series_list : data };
} else {
return { series_list : []};
}
}
</script>
<script>
import {link} from 'svelte-spa-router';
import {user} from '$/client/stores';
import Icon from '$/client/components/Icon.svelte';
import { onMount } from 'svelte';
import SnapImage from '$/client/components/SnapImage.svelte';
import Schedule from "$/client/components/Schedule.svelte";
import Layout from '$/rendered/Layout.svelte';
import { Hydrate } from '@jamcart/7ty/components';
export let series_list = [];
</script>
<style>
posts {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto;
grid-row-gap: 0.5rem;
grid-column-gap: 0.5rem;
}
post {
border: 1px solid black;
overflow: hidden;
position: relative;
}
post img {
width: 100%;
}
post post-title {
padding: 0px;
padding-left: 10px;
position: absolute;
bottom: 0;
color: var(--color-overlay-text);
background: hsla(0, 0%, 0%, 20%);
transition: background 0.5s ease-out;
width: 100%;
font-size: 0.9em;
}
post:hover post-title {
background: var(--color-overlay-background);
}
post post-title * {
margin: 0px;
}
hero.main:hover figure {
transform: scale(1.03);
filter: blur(4px);
opacity: 1;
}
hero.main:hover cover {
color: var(--value7);
text-shadow: var(--box-shadow) var(--color-shadow);
}
hero.main cover a i {
background-color: var(--color-bg-secondary);
}
hero.middle {
background-color: var(--color-bg-tertiary);
padding-top: 5rem;
padding-bottom: 5rem;
box-shadow: 0 8px 6px -6px black;
border-radius: 0px 0px 5px 5px;
}
hero.middle button {
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--box-shadow) black;
}
hero.middle section aside {
background-color: var(--color-bg);
}
hero.bottom {
background-color: var(--color-bg-tertiary);
padding-top: 5rem;
padding-bottom: 5rem;
box-shadow: 0 8px 6px -6px black;
border-radius: 0px 0px 5px 5px;
max-width: 100%;
}
current-series {
display: flex;
flex-direction: column;
}
current-series h1 {
display: flex;
align-self: center;
margin-bottom: 2rem;
margin-top: 0rem;
font-size: 3em;
}
current-series series {
display: flex;
flex-direction: row;
margin-bottom: 0.5rem;
}
current-series series * {
}
current-series series figure {
display: flex;
flex-basis: 50%;
}
current-series series figure a {
width: 100%;
}
current-series series figure img {
border-radius: 5px 0px 0px 5px;
}
current-series series description {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 0.5rem;
flex-basis: 50%;
background-color: var(--color-bg-secondary);
border-radius: 0px 5px 5px 0px;
}
current-series series description h2,p {
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
current-series series description stats {
display: flex;
justify-content: space-between;
align-items: baseline;
}
current-series series description stats span {
font-size: 1.2em;
font-weight: bold;
}
@media only screen and (max-width: 900px) {
posts {
grid-template-columns: repeat(2, 1fr);
}
current-series series {
flex-direction: column;
}
current-series series figure img {
border-radius: 5px 5px 0px 0px;
}
current-series series description {
border-radius: 0px 0px 5px 5px;
}
}
@media only screen and (max-width: 600px) {
posts {
grid-template-columns: 1fr;
}
current-series series {
flex-direction: column;
}
current-series series figure img {
border-radius: 5px 5px 0px 0px;
}
current-series series description {
border-radius: 0px 0px 5px 5px;
font-size: 0.8em;
}
}
</style>
<Layout bare={ true }>
<hero class="main">
<figure>
<Hydrate component={ SnapImage } props={ { height: "400", width: "800", colors: series_list[2].streams[0].colors, src: series_list[2].streams[0].thumbnail} } />
</figure>
<cover>
<h1>I Die A Lot</h1>
<p>A gaming video experiment.</p>
<a href="/client/#/watch/" use:link><i>Watch The Stream</i></a>
</cover>
</hero>
<main>
<posts>
{#each series_list as series, series_id}
{#each series.streams as stream, stream_id}
{#if !stream.live }
<post>
<a href="/client/#/series/{ series.id }/stream/{ stream.id }" use:link>
{#if stream.colors }
<Hydrate component={ SnapImage } props={ { height: "207", width: "411", colors: stream.colors, src: stream.thumbnail} } />
{:else}
<img src={ stream.thumbnail }>
{/if}
</a>
<post-title>
<h4>{series.headline}</h4>
<p>{stream.headline}</p>
</post-title>
</post>
{/if}
{/each}
{/each}
</posts>
<hero class="middle">
<a href="/client/#/register/" use:link><button>Register For Free To Get Even More Awesomeness</button></a>
<section id="signup">
<aside>
<figure>
<img src="/thumbs/7_days_to_die/thumb0045.jpeg">
</figure>
<h1>Watch Me Die, Repeatedly</h1>
<p>Signup for free to see more epic fails like this as I continue to demonstrate how much I love gaming despite being terrible at it. Subscribers will receive tastefully crafted emails telling them when I'm streaming...<b>because we know you'll never remember.</b></p>
</aside>
<aside>
<figure>
<img src="/thumbs/valheim/thumb0180.jpeg">
</figure>
<h1>Watch Me Code</h1>
<p>I <b>also</b> live stream much of the programming for this very site. Most of this content will be in my (soon to be released) <a href="https://learnjsthehardway.com">Learn JavaScript the Hard Way</a> online course so register and you can get it for <b>free</b>.</p>
</aside>
</section>
</hero>
<hr>
<current-series>
<h1>The Currently Running Series</h1>
{#each series_list as series, series_id}
<series>
<figure>
<a href="/client/#/series/{ series.id }/" use:link>
<Hydrate component={ SnapImage } props={ { height: "400", width: "800", colors: series.streams[0].colors, src: series.streams[0].thumbnail} } />
</a>
</figure>
<description>
<h2>{series.headline}</h2>
<p>{series.description}</p>
<stats>
<span>20 videos</span> <span>Last updated Nov 12</span> <button>Watch</button>
</stats>
</description>
</series>
{/each}
</current-series>
<hr>
<Schedule series_list={ series_list } />
</main>
</Layout>

51
rendered/pages/test_large_card.svelte

@ -1,51 +0,0 @@
<script>
import Layout from "$/rendered/Layout.svelte";
import OGPreview from "$/client/components/OGPreview.svelte";
import { base_host } from "$/client/config.js";
let og = {
"title": "LJSTHW OG Card Test", // title of the article
"description": "A simple test of the OG style of sharing cards.", // description for inside preview
"image": `${ base_host }/images/zed.png`, // image to display, 5mb/1200/627 max
"url": `${ base_host }/test_large_card.html`, // URL to article
"type": "website", // not mentioned on linked in but needed
}
let twitter = {
"card": "summary_large_image", // must be summary, summary_large_image, app, player
"creator": "@lzsthw", // @username of content creator
"description": og.description, // max 200 chars
"image": og.image,
"image:alt": "A drawing of Zed by Zed.", // max 420 chars image alt
"site": "@lzsthw", // @username of site
"title": og.title,
}
</script>
<style>
card {
width: 600px;
}
card top {
background-color: var(--yellow);
text-align: center;
}
</style>
<OGPreview og={ og } twitter={ twitter} />
<Layout centered={ true }>
<card>
<top>
<!-- this is how you assign a set of properties to a component using the {...} syntax -->
<img src="/images/zed.png" />
</top>
<middle>
<h4>OG Twitter Test</h4>
<p>If you visit <a href="https://cards-dev.twitter.com/validator">the Twitter validator tool</a> and enter an internet accessible link to this demo it will show the above image and a description.
</p>
</middle>
</card>
</Layout>

64
rendered/pages/test_video_card.svelte

@ -1,64 +0,0 @@
<script>
import Layout from "$/rendered/Layout.svelte";
import OGPreview from "$/client/components/OGPreview.svelte";
import { Hydrate } from '@jamcart/7ty/components';
import { base_host } from "$/client/config.js";
import Video from "$/client/components/Video.svelte";
let source = "/videos/sample.mp4";
let poster = "/images/zed.png";
let video_background = "rgba(0,0,0,0)";
let video_props = {
poster,
background_color: video_background,
source,
starts_on: new Date(Date.now() + 10000)
}
let og = {
"title": "LJSTHW OG/Twitter Video Test", // title of the article
"description": "A simple test of the OG/Twitter style of sharing cards with video.", // description for inside preview
"image": `${ base_host }/images/zed.png`, // image to display, 5mb/1200/627 max
"url": `${ base_host }/test_video_card.html`, // URL to article
"type": "website", // not mentioned on linked in but needed
}
let twitter = {
"card": "player", // must be summary, summary_large_image, app, player
"creator": "@lzsthw", // @username of content creator
"description": og.description, // max 200 chars
"image": og.image,
"image:alt": "A drawing of Zed by Zed.", // max 420 chars image alt
"site": "@lzsthw", // @username of site
"title": og.title,
"player": `${ base_host }/video_container.html`,
"player:width": "1280",
"player:height": "720"
}
</script>
<style>
card {
width: 600px;
}
card top {
background-color: var(--yellow);
text-align: center;
}
</style>
<OGPreview og={ og } twitter={ twitter} />
<Layout centered={ true }>
<card>
<top>
<Hydrate component={ Video } props={ video_props } />
</top>
<middle>
<h4>OG/Twitter Video</h4>
<p>If you visit <a href="https://cards-dev.twitter.com/validator">the Twitter validator tool</a> and enter an internet accessible link to this demo it will show the above video and a description.
</p>
</middle>
</card>
</Layout>

17
rendered/pages/video_container.svelte

@ -1,17 +0,0 @@
<script>
import Video from "$/client/components/Video.svelte";
import { Hydrate } from '@jamcart/7ty/components';
let source = "/videos/sample.mp4";
let poster = "/images/zed.png";
let video_background = "rgba(0,0,0,0)";
let video_props = {
poster,
background_color: video_background,
source,
starts_on: new Date(Date.now() + 10000)
}
</script>
<Hydrate component={ Video } props={ video_props } />

56
scripts/loader.js

@ -0,0 +1,56 @@
import path from "path"
import fs from "fs"
import { execSync as exec } from "child_process"
import glob from "fast-glob";
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 find_screenshots = (video_file) => {
// parse the filename and extract the date
let pinfo = path.parse(video_file);
const [target_name, target_dir] = [pinfo.name, pinfo.dir.replace(/\\/g,"/")];
// this pulls out the date/time without seconds
const extractor = /Replay_(.*)_(.*)-[0-9][0-9]\.1080/
const dt = target_name.match(extractor);
const [date, time] = [dt[1], dt[2]];
// look for the closest screenshot
// glob doesn't get path.join on windows. LOL.
const glob_pattern = `${target_dir}/Screenshot ${date} ${time}*.png`;
return glob.sync([glob_pattern]);
}
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 meta = JSON.parse(stdout);
// find the screenshots
meta.screens = find_screenshots(meta.format.filename);
console.log(fname, meta.screens.length);
meta.format.ctime = fs.statSync(fname).ctime;
return meta;
} else {
return JSON.parse(fs.readFileSync(jf));
}
}
const video_files = glob.sync(input.replace(/\\/g,"/"));
const videos = video_files.map(f => video_meta(f, force));
videos.forEach(v => {
fs.writeFileSync(json_path(v.format.filename), JSON.stringify(v));
});

193
temp_data.js

@ -0,0 +1,193 @@
export const series_data = [
{
"id": 0,
"headline": "The Forest",
"thumbnail": "/thumbs/the_forest/thumb0004.jpeg",
"description": "A brutal and realistic survival game that has you stranded on an island fighting mutant cannibals to rescue your son Timmy. I've beat this game once already in normal mode so now I'm playing in hard survival.",
"stream_day": 0,
"stream_time": "3pm",
"streams": [
{
"id": 2,
"headline": "Fighting Three Masks",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 0,
"video": "/videos/The_Forest/the_forest_fighting_three_masks.out.mp4",
"thumbnail": "/thumbs/the_forest/thumb0054.jpeg",
"colors": [[61,55,43],[198,181,129],[161,145,96],[177,174,100],[126,121,81],[115,98,73],[147,151,167],[105,94,118],[199,66,30],[194,142,51]],
},
{
"id": 0,
"headline": "Machete Go Chop Chop",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 0,
"video": "/videos/The_Forest/flint_lock_in_samurai_cave.out.mp4",
"thumbnail": "/thumbs/the_forest/thumb0004.jpeg",
"colors": [ [ 187, 28, 36 ], [ 152, 24, 27 ], [ 18, 12, 27 ], [ 95, 23, 33 ], [ 242, 227, 171 ], [ 144, 163, 166 ], [ 76, 69, 101 ], [ 48, 96, 185 ], [ 213, 133, 152 ], [ 102, 77, 61 ] ]
},
{
"id": 1,
"headline": "Finding Timmy",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 0,
"thumbnail": "/thumbs/the_forest/thumb0025.jpeg",
"video": "/videos/The_Forest/the_forest_finding_timmy.out.mp4",
"colors": [ [ 179, 113, 64 ], [ 61, 42, 28 ], [ 138, 86, 55 ], [ 9, 5, 16 ], [ 96, 75, 69 ], [ 238, 215, 161 ], [ 141, 114, 108 ], [ 105, 92, 66 ], [ 156, 140, 96 ], [ 196, 184, 156 ] ]
}
]
},
{
"id": 1,
"headline": "7 Days to Die",
"thumbnail": "/thumbs/7_days_to_die/thumb0045.jpeg",
"stream_day": 2,
"stream_time": "7pm",
"description": "Never has a game been so good while being so badly programmed. Teleporting bears, zombies spawning in right on top of you, and crashing but it's still the best game on the list right now.",
"streams": [
{
"id": 4,
"headline": "My First Wasteland Horde",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 1,
"video": "/videos/7D2D/7d2d_wasteland_robbery_2.out.mp4",
"thumbnail": "/thumbs/7_days_to_die/thumb0010.jpeg",
"colors": [[185,157,148],[17,109,15],[27,197,21],[4,21,4],[6,68,6],[63,69,176],[83,75,78],[36,39,100],[45,42,40],[124,148,220]]
},
{
"id": 3,
"thumbnail": "/thumbs/7_days_to_die/thumb0045.jpeg",
"headline": "Stupid Birds!",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 1,
"video": "/videos/7D2D/7d2d_wasteland_horde_1.out.mp4",
"colors": [ [ 47, 29, 26 ], [ 165, 147, 138 ], [ 104, 38, 42 ], [ 236, 215, 194 ], [ 95, 89, 95 ], [ 110, 74, 53 ], [ 134, 89, 87 ], [ 220, 182, 156 ], [ 204, 190, 197 ], [ 143, 112, 89 ] ]
}
]
},
{
"id": 2,
"headline": "Valheim",
"thumbnail": "/thumbs/valheim/thumb0180.jpeg",
"stream_day": 3,
"stream_time": "3pm",
"description": "A beautiful game that's still in early access but feels like it's a finished product. I'm close to day 400 and am almost done with the content that's released so far, but haven't beat the final boss yet.",
"streams": [
{
"id": 6,
"headline": "Killing Bonemass is Easy",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 2,
"video": "/videos/Valheim/284_defeating_bonemass.out.mp4",
"thumbnail": "/thumbs/valheim/thumb0025.jpeg",
"colors": [ [ 44, 77, 26 ], [ 127, 187, 60 ], [ 189, 160, 115 ], [ 88, 153, 44 ], [ 25, 31, 14 ], [ 124, 151, 61 ], [ 89, 115, 43 ], [ 65, 122, 34 ], [ 108, 149, 173 ], [ 89, 119, 109 ] ]
},
{
"id": 5,
"thumbnail": "/thumbs/valheim/thumb0180.jpeg",
"headline": "I Am Ahab",
"video": "/videos/Valheim/i_am_ahab_2.out.mp4",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 2,
"colors": [ [ 190, 188, 171 ], [ 100, 192, 209 ], [ 21, 73, 65 ], [ 41, 151, 177 ], [ 200, 42, 40 ], [ 80, 134, 125 ], [ 114, 78, 69 ], [ 35, 109, 124 ], [ 7, 15, 17 ], [ 213, 108, 59 ] ]
},
{
"id": 7,
"headline": "Moder Is Kind of Broken",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 2,
"thumbnail": "/thumbs/valheim/thumb0811.jpeg",
"video": "/videos/Valheim/332_beat_moder.out.mp4",
"colors": [ [ 93, 196, 196 ], [ 197, 36, 106 ], [ 202, 226, 231 ], [ 24, 25, 41 ], [ 55, 104, 124 ], [ 128, 61, 105 ], [ 102, 137, 175 ], [ 47, 60, 87 ], [ 37, 97, 95 ], [ 183, 136, 192 ] ]
}
]
},
{
"id": 3,
"headline": "Citadel: Forged in Fire",
"thumbnail": "/thumbs/citadel/thumb0013.jpeg",
"stream_day": 4,
"stream_time": "2pm",
"description": "An absolutely gorgeous game that is a unique magical crafting survival game in a giant world. Best of all, you use magic to gather resources rather than a stupid pickaxe.",
"streams": [
{
"id": 8,
"headline": "Such a Pretty Game",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 3,
"video": "/videos/Citadel/citadel_2.out.mp4",
"thumbnail": "/thumbs/citadel/thumb0013.jpeg",
"colors": [ [ 26, 10, 19 ], [ 90, 76, 160 ], [ 44, 15, 59 ], [ 61, 25, 95 ], [ 100, 63, 87 ], [ 70, 83, 44 ], [ 64, 47, 42 ], [ 185, 166, 144 ], [ 103, 159, 164 ], [ 47, 49, 16 ] ]
},
{
"id": 9,
"headline": "Skeletons and Fairies Guarding",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 3,
"video": "/videos/Citadel/citadel_4.out.mp4",
"thumbnail": "/thumbs/citadel/thumb0030.jpeg",
"colors": [ [ 63, 99, 163 ], [ 86, 165, 199 ], [ 19, 11, 23 ], [ 44, 62, 117 ], [ 191, 141, 151 ], [ 46, 185, 41 ], [ 109, 24, 31 ], [ 32, 30, 70 ], [ 32, 98, 27 ], [ 69, 12, 21 ] ]
}
]
},
{
"id": 4,
"headline": "Subnautica: Below Zero",
"thumbnail": "/thumbs/subnautica_below_zero/thumb0026.jpeg",
"stream_day": 4,
"stream_time": "7pm",
"description": "The sequel to Subnautica, or is it a pre-quel? I can't tell but it's fun to play even if it's not quite as good as the original.",
"streams": [
{
"id": 10,
"headline": "Keeping Warm on An Arctic World",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 4,
"video": "/videos/BelowZero/01_finding_station_zero.out.mp4",
"thumbnail": "/thumbs/subnautica_below_zero/thumb0110.jpeg",
"colors": [ [ 210, 60, 88 ], [ 37, 23, 19 ], [ 139, 63, 53 ], [ 220, 208, 208 ], [ 115, 185, 171 ], [ 102, 47, 39 ], [ 75, 40, 36 ], [ 98, 104, 99 ], [ 99, 127, 159 ], [ 80, 81, 94 ] ]
},
{
"id": 11,
"headline": "Finding the First Base Station",
"likes": 0,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 4,
"video": "/videos/BelowZero/02_found_station_zero.out.mp4",
"thumbnail": "/thumbs/subnautica_below_zero/thumb0026.jpeg",
"colors": [ [ 195, 203, 206 ], [ 40, 34, 37 ], [ 152, 134, 136 ], [ 168, 195, 198 ], [ 123, 114, 118 ], [ 104, 83, 83 ], [ 166, 160, 166 ], [ 113, 99, 109 ], [ 75, 79, 82 ], [ 210, 82, 68 ] ]
}
]
},
{
"id": 5,
"headline": "Live Coding",
"thumbnail": "",
"description": "The meta series where I code this website for everyone to watch.",
"stream_day": 0,
"stream_time": "3pm",
"streams": [
{
"id": 12,
"headline": "Jun 2, 2021",
"likes": 0,
"live": true,
"scheduled_for": "2021-04-22 20:59:43",
"series_id": 0,
"video": "/videos/livecoding/2021-04-27_14-52-12.mp4",
"thumbnail": "/thumbs/livecoding/thumb0008.jpeg",
"colors": [[209,209,209],[52,183,140],[5,5,5],[51,51,51],[112,91,85],[41,86,74],[219,212,36],[75,119,146],[160,88,86],[33,53,70]]
},
]
}
]
Loading…
Cancel
Save