aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--web/script/player/mod.ts2
-rw-r--r--web/script/player/player.ts5
-rw-r--r--web/script/player/profiles.ts2
-rw-r--r--web/script/player/track.ts207
-rw-r--r--web/script/player/track/create.ts12
-rw-r--r--web/script/player/track/mod.ts24
-rw-r--r--web/script/player/track/mse.ts140
-rw-r--r--web/script/player/track/vtt.ts80
-rw-r--r--web/style/player.css95
9 files changed, 291 insertions, 276 deletions
diff --git a/web/script/player/mod.ts b/web/script/player/mod.ts
index c4f4473..3d7cc66 100644
--- a/web/script/player/mod.ts
+++ b/web/script/player/mod.ts
@@ -12,7 +12,7 @@ import { TrackKind, get_track_kind } from "./mediacaps.ts";
import { Player } from "./player.ts";
import { Popup } from "./popup.ts";
import { Playersync } from "./sync.ts"
-import { MSEPlayerTrack } from "./track.ts";
+import { MSEPlayerTrack } from "./track/mse.ts";
globalThis.addEventListener("DOMContentLoaded", () => {
if (document.body.classList.contains("player")) {
diff --git a/web/script/player/player.ts b/web/script/player/player.ts
index c5ceed0..6589772 100644
--- a/web/script/player/player.ts
+++ b/web/script/player/player.ts
@@ -7,10 +7,11 @@
import { OVar, e } from "../jshelper/mod.ts";
import { NodePublic, NodeUserData, SourceTrack, TimeRange } from "./jhls.d.ts";
import { SegmentDownloader } from "./download.ts";
-import { PlayerTrack } from "./track.ts";
+import { PlayerTrack } from "./track/mod.ts";
import { Logger } from "../jshelper/src/log.ts";
import { WatchedState } from "./jhls.d.ts";
import { get_track_kind } from "./mediacaps.ts";
+import { create_track } from "./track/create.ts";
export interface BufferRange extends TimeRange { status: "buffered" | "loading" | "queued" }
export class Player {
@@ -139,7 +140,7 @@ export class Player {
const [track] = this.active_tracks.value.splice(active_index, 1)
track.abort.abort()
} else if (state && active_index == -1) {
- this.active_tracks.value.push((await PlayerTrack.new(this, this.node_id, index, this.tracks![index]))!)
+ this.active_tracks.value.push((await create_track(this, this.node_id, index, this.tracks![index]))!)
if (update) await this.update()
}
this.active_tracks.change()
diff --git a/web/script/player/profiles.ts b/web/script/player/profiles.ts
index fe0e64d..b23fbaf 100644
--- a/web/script/player/profiles.ts
+++ b/web/script/player/profiles.ts
@@ -8,7 +8,7 @@ import { OVar } from "../jshelper/mod.ts";
import { EncodingProfile } from "./jhls.d.ts";
import { profile_to_partial_track, test_media_capability } from "./mediacaps.ts";
import { Player } from "./player.ts";
-import { MSEPlayerTrack } from "./track.ts";
+import { MSEPlayerTrack } from "./track/mse.ts";
const PROFILE_UP_FAC = 0.6
const PROFILE_DOWN_FAC = 0.8
diff --git a/web/script/player/track.ts b/web/script/player/track.ts
deleted file mode 100644
index b6a280f..0000000
--- a/web/script/player/track.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- This file is part of jellything (https://codeberg.org/metamuffin/jellything)
- which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
- Copyright (C) 2024 metamuffin <metamuffin.org>
-*/
-/// <reference lib="dom" />
-import { JhlsTrackIndex, SourceTrack, TimeRange } from "./jhls.d.ts";
-import { OVar } from "../jshelper/mod.ts";
-import { profile_to_partial_track, track_to_content_type } from "./mediacaps.ts";
-import { BufferRange, Player } from "./player.ts";
-import { EncodingProfileExt, ProfileSelector } from "./profiles.ts";
-import { JvttCue } from "./jhls.d.ts";
-import { get_track_kind } from "./mediacaps.ts";
-
-export const TARGET_BUFFER_DURATION = 10
-export const MIN_BUFFER_DURATION = 1
-
-export interface AppendRange extends TimeRange { buf: ArrayBuffer, index: number, cb: () => void }
-
-export abstract class PlayerTrack {
- public static async new(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<PlayerTrack | undefined> {
- const kind = get_track_kind(metadata.kind)
- if (kind == "subtitles") return await VttPlayerTrack.new(player, node_id, track_index, metadata)
- else return await MSEPlayerTrack.new(player, node_id, track_index, metadata)
- }
-
- constructor(
- public track_index: number,
- ) { }
- public buffered = new OVar<BufferRange[]>([]);
- public abort = new AbortController()
- async update(_target: number) { }
-}
-
-export class VttPlayerTrack extends PlayerTrack {
- private track: TextTrack
-
- public static async new(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<VttPlayerTrack | undefined> {
- try {
- const res = await fetch(`/n/${encodeURIComponent(player.node_id)}/stream?format=jvtt&tracks=${track_index}`, { headers: { "Accept": "application/json" } })
- if (!res.ok) return player.error.value = "Cannot download index.", undefined
- let index!: JvttCue[] & { error: string }
- try { index = await res.json() }
- catch (_) { player.set_pers("Error: Failed to fetch node") }
- if (index.error) return player.set_pers("server error: " + index.error), undefined
-
- const t = new VttPlayerTrack(player, node_id, track_index, metadata, index)
- return t
- } catch (e) {
- if (e instanceof TypeError) {
- player.set_pers("Cannot download subtitles: Network Error")
- } else throw e
- }
- }
- constructor(
- private player: Player,
- private node_id: string,
- track_index: number,
- private metadata: SourceTrack,
- public cues: JvttCue[],
- ) {
- super(track_index)
- this.track = this.player.video.addTextTrack("subtitles", metadata.name, metadata.language)
- for (const cue of cues) {
- const c = new VTTCue(cue.start, cue.end, cue.content);
- c.line = -2;
- this.track.addCue(c)
- }
- this.track.mode = "showing"
- this.abort.signal.addEventListener("abort", () => {
- // TODO disable subtitles properly
- this.track.mode = "hidden"
- })
- }
-}
-
-export class MSEPlayerTrack extends PlayerTrack {
- public source_buffer!: SourceBuffer;
- private current_load?: AppendRange;
- private loading = new Set<number>();
- private append_queue: AppendRange[] = [];
- public profile_selector: ProfileSelector
- public profile = new OVar<EncodingProfileExt | undefined>(undefined);
-
- public static async new(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<MSEPlayerTrack | undefined> {
- try {
- const res = await fetch(`/n/${encodeURIComponent(player.node_id)}/stream?format=jhlsi&tracks=${track_index}`, { headers: { "Accept": "application/json" } })
- if (!res.ok) return player.error.value = "Cannot download index.", undefined
- let index!: JhlsTrackIndex & { error: string }
- try { index = await res.json() }
- catch (_) { player.set_pers("Error: Failed to fetch node") }
- if (index.error) return player.set_pers("server error: " + index.error), undefined
-
- const t = new MSEPlayerTrack(player, node_id, track_index, metadata, index)
- await t.init()
- return t
- } catch (e) {
- if (e instanceof TypeError) {
- player.set_pers("Cannot download index: Network Error")
- } else throw e
- }
- }
- constructor(
- private player: Player,
- private node_id: string,
- track_index: number,
- private metadata: SourceTrack,
- public index: JhlsTrackIndex,
- ) {
- super(track_index)
- this.profile_selector = new ProfileSelector(player, this, player.downloader.bandwidth)
- }
- async init() {
- await this.profile_selector.select_optimal_profile(this.track_index, this.profile);
- const ct = track_to_content_type(this.track_from_profile())!
- console.log(`track ${this.track_index} source buffer content-type: ${ct}`);
- this.source_buffer = this.player.media_source.addSourceBuffer(ct);
- this.abort.signal.addEventListener("abort", () => {
- console.log(`destroy source buffer for track ${this.track_index}`);
- this.player.media_source.removeSourceBuffer(this.source_buffer)
- })
- this.source_buffer.mode = "segments";
- this.source_buffer.addEventListener("updateend", () => {
- if (this.abort.signal.aborted) return
- if (this.current_load) {
- this.current_load.cb();
- this.loading.delete(this.current_load.index);
- this.current_load = undefined;
- }
- this.update_buf_ranges();
- this.tick_append();
- });
- this.source_buffer.addEventListener("error", e => {
- console.error("sourcebuffer error", e);
- });
- this.source_buffer.addEventListener("abort", e => {
- console.error("sourcebuffer abort", e);
- });
- }
- track_from_profile(): SourceTrack {
- if (this.profile.value) return profile_to_partial_track(this.profile.value)
- else return this.metadata
- }
-
- update_buf_ranges() {
- const ranges: BufferRange[] = [];
- for (let i = 0; i < this.source_buffer.buffered.length; i++) {
- ranges.push({
- start: this.source_buffer.buffered.start(i),
- end: this.source_buffer.buffered.end(i),
- status: "buffered"
- });
- }
- for (const r of this.loading) {
- ranges.push({ ...this.index.segments[r], status: "loading" });
- }
- this.buffered.value = ranges;
- }
-
- async update(target: number) {
- this.update_buf_ranges(); // TODO required?
-
- const blocking = [];
- for (let i = 0; i < this.index.segments.length; i++) {
- const seg = this.index.segments[i];
- if (seg.end < target) continue;
- if (seg.start >= target + TARGET_BUFFER_DURATION) break;
- if (!this.check_buf_collision(seg.start, seg.end)) continue;
- if (seg.start <= target + MIN_BUFFER_DURATION)
- blocking.push(this.load(i));
- else
- this.load(i);
- }
- await Promise.all(blocking);
- }
- check_buf_collision(start: number, end: number) {
- const EPSILON = 0.01;
- for (const r of this.buffered.value)
- if (r.end - EPSILON > start && r.start < end - EPSILON)
- return false;
- return true;
- }
-
- async load(index: number) {
- this.loading.add(index);
- await this.profile_selector.select_optimal_profile(this.track_index, this.profile);
- const url = `/n/${encodeURIComponent(this.node_id)}/stream?format=snippet&webm=true&tracks=${this.track_index}&index=${index}${this.profile.value ? `&profile=${this.profile.value.id}` : ""}`;
- const buf = await this.player.downloader.download(url);
- await new Promise<void>(cb => {
- if (this.abort.signal.aborted) return
- this.append_queue.push({ buf, ...this.index.segments[index], index, cb });
- this.tick_append();
- });
- }
- tick_append() {
- if (this.source_buffer.updating) return;
- if (this.append_queue.length) {
- const seg = this.append_queue[0];
- this.append_queue.splice(0, 1);
- this.current_load = seg;
- // TODO why is appending so unreliable?! sometimes it does not add it
- this.source_buffer.changeType(track_to_content_type(this.track_from_profile())!);
- this.source_buffer.timestampOffset = seg.start;
- this.source_buffer.appendBuffer(seg.buf);
- }
- }
-}
diff --git a/web/script/player/track/create.ts b/web/script/player/track/create.ts
new file mode 100644
index 0000000..f674c3a
--- /dev/null
+++ b/web/script/player/track/create.ts
@@ -0,0 +1,12 @@
+import { get_track_kind } from "../mediacaps.ts";
+import { create_vtt_track } from "./vtt.ts";
+import { create_mse_track } from "./mse.ts";
+import { Player } from "../player.ts";
+import { SourceTrack } from "../jhls.d.ts";
+import { PlayerTrack } from "./mod.ts";
+
+export async function create_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<PlayerTrack | undefined> {
+ const kind = get_track_kind(metadata.kind)
+ if (kind == "subtitles") return await create_vtt_track(player, node_id, track_index, metadata)
+ else return await create_mse_track(player, node_id, track_index, metadata)
+}
diff --git a/web/script/player/track/mod.ts b/web/script/player/track/mod.ts
new file mode 100644
index 0000000..cdb07cc
--- /dev/null
+++ b/web/script/player/track/mod.ts
@@ -0,0 +1,24 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2024 metamuffin <metamuffin.org>
+*/
+/// <reference lib="dom" />
+import { TimeRange } from "../jhls.d.ts";
+import { OVar } from "../../jshelper/mod.ts";
+import { BufferRange } from "../player.ts";
+
+export const TARGET_BUFFER_DURATION = 10
+export const MIN_BUFFER_DURATION = 1
+
+export interface AppendRange extends TimeRange { buf: ArrayBuffer, index: number, cb: () => void }
+
+export abstract class PlayerTrack {
+ constructor(
+ public track_index: number,
+ ) { }
+ public buffered = new OVar<BufferRange[]>([]);
+ public abort = new AbortController()
+ async update(_target: number) { }
+}
+
diff --git a/web/script/player/track/mse.ts b/web/script/player/track/mse.ts
new file mode 100644
index 0000000..623e149
--- /dev/null
+++ b/web/script/player/track/mse.ts
@@ -0,0 +1,140 @@
+import { JhlsTrackIndex, SourceTrack } from "../jhls.d.ts";
+import { OVar } from "../../jshelper/mod.ts";
+import { profile_to_partial_track, track_to_content_type } from "../mediacaps.ts";
+import { BufferRange, Player } from "../player.ts";
+import { EncodingProfileExt, ProfileSelector } from "../profiles.ts";
+import { PlayerTrack, AppendRange, TARGET_BUFFER_DURATION, MIN_BUFFER_DURATION } from "./mod.ts";
+
+export async function create_mse_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<MSEPlayerTrack | undefined> {
+ try {
+ const res = await fetch(`/n/${encodeURIComponent(player.node_id)}/stream?format=jhlsi&tracks=${track_index}`, { headers: { "Accept": "application/json" } });
+ if (!res.ok) return player.error.value = "Cannot download index.", undefined;
+ let index!: JhlsTrackIndex & { error: string; };
+ try { index = await res.json(); }
+ catch (_) { player.set_pers("Error: Failed to fetch node"); }
+ if (index.error) return player.set_pers("server error: " + index.error), undefined;
+
+ const t = new MSEPlayerTrack(player, node_id, track_index, metadata, index);
+ await t.init();
+ return t;
+ } catch (e) {
+ if (e instanceof TypeError) {
+ player.set_pers("Cannot download index: Network Error");
+ } else throw e;
+ }
+}
+
+export class MSEPlayerTrack extends PlayerTrack {
+ public source_buffer!: SourceBuffer;
+ private current_load?: AppendRange;
+ private loading = new Set<number>();
+ private append_queue: AppendRange[] = [];
+ public profile_selector: ProfileSelector;
+ public profile = new OVar<EncodingProfileExt | undefined>(undefined);
+
+ constructor(
+ private player: Player,
+ private node_id: string,
+ track_index: number,
+ private metadata: SourceTrack,
+ public index: JhlsTrackIndex
+ ) {
+ super(track_index);
+ this.profile_selector = new ProfileSelector(player, this, player.downloader.bandwidth);
+ }
+ async init() {
+ await this.profile_selector.select_optimal_profile(this.track_index, this.profile);
+ const ct = track_to_content_type(this.track_from_profile())!;
+ console.log(`track ${this.track_index} source buffer content-type: ${ct}`);
+ this.source_buffer = this.player.media_source.addSourceBuffer(ct);
+ this.abort.signal.addEventListener("abort", () => {
+ console.log(`destroy source buffer for track ${this.track_index}`);
+ this.player.media_source.removeSourceBuffer(this.source_buffer);
+ });
+ this.source_buffer.mode = "segments";
+ this.source_buffer.addEventListener("updateend", () => {
+ if (this.abort.signal.aborted) return;
+ if (this.current_load) {
+ this.current_load.cb();
+ this.loading.delete(this.current_load.index);
+ this.current_load = undefined;
+ }
+ this.update_buf_ranges();
+ this.tick_append();
+ });
+ this.source_buffer.addEventListener("error", e => {
+ console.error("sourcebuffer error", e);
+ });
+ this.source_buffer.addEventListener("abort", e => {
+ console.error("sourcebuffer abort", e);
+ });
+ }
+ track_from_profile(): SourceTrack {
+ if (this.profile.value) return profile_to_partial_track(this.profile.value);
+ else return this.metadata;
+ }
+
+ update_buf_ranges() {
+ const ranges: BufferRange[] = [];
+ for (let i = 0; i < this.source_buffer.buffered.length; i++) {
+ ranges.push({
+ start: this.source_buffer.buffered.start(i),
+ end: this.source_buffer.buffered.end(i),
+ status: "buffered"
+ });
+ }
+ for (const r of this.loading) {
+ ranges.push({ ...this.index.segments[r], status: "loading" });
+ }
+ this.buffered.value = ranges;
+ }
+
+ async update(target: number) {
+ this.update_buf_ranges(); // TODO required?
+
+ const blocking = [];
+ for (let i = 0; i < this.index.segments.length; i++) {
+ const seg = this.index.segments[i];
+ if (seg.end < target) continue;
+ if (seg.start >= target + TARGET_BUFFER_DURATION) break;
+ if (!this.check_buf_collision(seg.start, seg.end)) continue;
+ if (seg.start <= target + MIN_BUFFER_DURATION)
+ blocking.push(this.load(i));
+
+ else
+ this.load(i);
+ }
+ await Promise.all(blocking);
+ }
+ check_buf_collision(start: number, end: number) {
+ const EPSILON = 0.01;
+ for (const r of this.buffered.value)
+ if (r.end - EPSILON > start && r.start < end - EPSILON)
+ return false;
+ return true;
+ }
+
+ async load(index: number) {
+ this.loading.add(index);
+ await this.profile_selector.select_optimal_profile(this.track_index, this.profile);
+ const url = `/n/${encodeURIComponent(this.node_id)}/stream?format=snippet&webm=true&tracks=${this.track_index}&index=${index}${this.profile.value ? `&profile=${this.profile.value.id}` : ""}`;
+ const buf = await this.player.downloader.download(url);
+ await new Promise<void>(cb => {
+ if (this.abort.signal.aborted) return;
+ this.append_queue.push({ buf, ...this.index.segments[index], index, cb });
+ this.tick_append();
+ });
+ }
+ tick_append() {
+ if (this.source_buffer.updating) return;
+ if (this.append_queue.length) {
+ const seg = this.append_queue[0];
+ this.append_queue.splice(0, 1);
+ this.current_load = seg;
+ // TODO why is appending so unreliable?! sometimes it does not add it
+ this.source_buffer.changeType(track_to_content_type(this.track_from_profile())!);
+ this.source_buffer.timestampOffset = seg.start;
+ this.source_buffer.appendBuffer(seg.buf);
+ }
+ }
+}
diff --git a/web/script/player/track/vtt.ts b/web/script/player/track/vtt.ts
new file mode 100644
index 0000000..c426fa1
--- /dev/null
+++ b/web/script/player/track/vtt.ts
@@ -0,0 +1,80 @@
+import { SourceTrack, JvttCue } from "../jhls.d.ts";
+import { Player } from "../player.ts";
+import { PlayerTrack } from "./mod.ts";
+
+export async function create_vtt_track(player: Player, node_id: string, track_index: number, metadata: SourceTrack): Promise<VttPlayerTrack | undefined> {
+ let index: JvttCue[];
+ try {
+ const res = await fetch(`/n/${encodeURIComponent(player.node_id)}/stream?format=jvtt&tracks=${track_index}`, { headers: { "Accept": "application/json" } });
+ if (!res.ok) return player.error.value = "Cannot download index.", undefined;
+ let ai!: JvttCue[] & { error: string; };
+ try { ai = await res.json(); }
+ catch (_) { player.set_pers("Error: Failed to fetch node"); }
+ if (ai.error) return player.set_pers("server error: " + ai.error), undefined;
+ index = ai;
+ } catch (e) {
+ if (e instanceof TypeError) {
+ player.set_pers("Cannot download subtitles: Network Error");
+ return undefined
+ } else throw e;
+ }
+ const t = new VttPlayerTrack(player, node_id, track_index, metadata, index);
+ return t;
+}
+
+export class VttPlayerTrack extends PlayerTrack {
+ private track: TextTrack;
+
+ constructor(
+ private player: Player,
+ private node_id: string,
+ track_index: number,
+ private metadata: SourceTrack,
+ public cues: JvttCue[]
+ ) {
+ super(track_index);
+ this.track = this.player.video.addTextTrack("subtitles", metadata.name, metadata.language);
+ for (const cue of cues) {
+ this.track.addCue(create_cue(cue));
+ }
+ this.track.mode = "showing";
+ this.abort.signal.addEventListener("abort", () => {
+ // TODO disable subtitles properly
+ this.track.mode = "hidden";
+ });
+ }
+}
+
+function create_cue(cue: JvttCue): VTTCue {
+ const c = new VTTCue(cue.start, cue.end, cue.content);
+ const props = parse_layout_properties(cue.content.split("\n")[0])
+ if (props) {
+ c.text = cue.content.split("\n").slice(1).join("\n")
+ // TODO this does not work at all...
+ const region = new VTTRegion()
+ if ("position" in props && props.position.endsWith("%"))
+ region.regionAnchorX = parseFloat(props.position.replace("%", ""))
+ if ("line" in props && props.line.endsWith("%"))
+ region.regionAnchorY = parseFloat(props.line.replace("%", ""))
+ if ("align" in props)
+ c.align = props.align as AlignSetting
+ c.region = region
+ } else {
+ c.line = -2;
+ }
+ return c
+}
+
+function parse_layout_properties(s: string): undefined | Record<string, string> {
+ const o: Record<string, string> = {}
+ for (const tok of s.split(" ")) {
+ const [k, v, ...rest] = tok.split(":")
+ if (!v || rest.length) return undefined
+ o[k] = v
+ }
+ // some common keys to prevent false positives
+ if ("position" in o) return o
+ if ("align" in o) return o
+ if ("line" in o) return o
+ return undefined
+}
diff --git a/web/style/player.css b/web/style/player.css
index 34e1bfb..246f7c6 100644
--- a/web/style/player.css
+++ b/web/style/player.css
@@ -67,69 +67,34 @@ video::cue {
/* TODO this is inefficient */
/* print(", ".join([f"{x/19}em {y/19}em black" for x in range(-5,6) for y in range(-5,6) if x*x+y*y < 5*5])) */
background-color: transparent;
- text-shadow: -0.21052631578947367em -0.10526315789473684em black,
- -0.21052631578947367em -0.05263157894736842em black,
- -0.21052631578947367em 0em black,
- -0.21052631578947367em 0.05263157894736842em black,
- -0.21052631578947367em 0.10526315789473684em black,
- -0.15789473684210525em -0.15789473684210525em black,
- -0.15789473684210525em -0.10526315789473684em black,
- -0.15789473684210525em -0.05263157894736842em black,
- -0.15789473684210525em 0em black,
- -0.15789473684210525em 0.05263157894736842em black,
- -0.15789473684210525em 0.10526315789473684em black,
- -0.15789473684210525em 0.15789473684210525em black,
- -0.10526315789473684em -0.21052631578947367em black,
- -0.10526315789473684em -0.15789473684210525em black,
- -0.10526315789473684em -0.10526315789473684em black,
- -0.10526315789473684em -0.05263157894736842em black,
- -0.10526315789473684em 0em black,
- -0.10526315789473684em 0.05263157894736842em black,
- -0.10526315789473684em 0.10526315789473684em black,
- -0.10526315789473684em 0.15789473684210525em black,
- -0.10526315789473684em 0.21052631578947367em black,
- -0.05263157894736842em -0.21052631578947367em black,
- -0.05263157894736842em -0.15789473684210525em black,
- -0.05263157894736842em -0.10526315789473684em black,
- -0.05263157894736842em -0.05263157894736842em black,
- -0.05263157894736842em 0em black,
- -0.05263157894736842em 0.05263157894736842em black,
- -0.05263157894736842em 0.10526315789473684em black,
- -0.05263157894736842em 0.15789473684210525em black,
- -0.05263157894736842em 0.21052631578947367em black,
- 0em -0.21052631578947367em black, 0em -0.15789473684210525em black,
- 0em -0.10526315789473684em black, 0em -0.05263157894736842em black,
- 0em 0em black, 0em 0.05263157894736842em black,
- 0em 0.10526315789473684em black, 0em 0.15789473684210525em black,
- 0em 0.21052631578947367em black,
- 0.05263157894736842em -0.21052631578947367em black,
- 0.05263157894736842em -0.15789473684210525em black,
- 0.05263157894736842em -0.10526315789473684em black,
- 0.05263157894736842em -0.05263157894736842em black,
- 0.05263157894736842em 0em black,
- 0.05263157894736842em 0.05263157894736842em black,
- 0.05263157894736842em 0.10526315789473684em black,
- 0.05263157894736842em 0.15789473684210525em black,
- 0.05263157894736842em 0.21052631578947367em black,
- 0.10526315789473684em -0.21052631578947367em black,
- 0.10526315789473684em -0.15789473684210525em black,
- 0.10526315789473684em -0.10526315789473684em black,
- 0.10526315789473684em -0.05263157894736842em black,
- 0.10526315789473684em 0em black,
- 0.10526315789473684em 0.05263157894736842em black,
- 0.10526315789473684em 0.10526315789473684em black,
- 0.10526315789473684em 0.15789473684210525em black,
- 0.10526315789473684em 0.21052631578947367em black,
- 0.15789473684210525em -0.15789473684210525em black,
- 0.15789473684210525em -0.10526315789473684em black,
- 0.15789473684210525em -0.05263157894736842em black,
- 0.15789473684210525em 0em black,
- 0.15789473684210525em 0.05263157894736842em black,
- 0.15789473684210525em 0.10526315789473684em black,
- 0.15789473684210525em 0.15789473684210525em black,
- 0.21052631578947367em -0.10526315789473684em black,
- 0.21052631578947367em -0.05263157894736842em black,
- 0.21052631578947367em 0em black,
- 0.21052631578947367em 0.05263157894736842em black,
- 0.21052631578947367em 0.10526315789473684em black;
+ text-shadow: 0em 0.1em black,
+ 0.02079116908177593em 0.09781476007338058em black,
+ 0.040673664307580015em 0.0913545457642601em black,
+ 0.058778525229247314em 0.08090169943749476em black,
+ 0.07431448254773941em 0.06691306063588583em black,
+ 0.08660254037844387em 0.05000000000000002em black,
+ 0.09510565162951536em 0.030901699437494747em black,
+ 0.09945218953682733em 0.010452846326765346em black,
+ 0.09945218953682734em -0.010452846326765334em black,
+ 0.09510565162951537em -0.030901699437494736em black,
+ 0.08660254037844388em -0.04999999999999998em black,
+ 0.07431448254773945em -0.0669130606358858em black,
+ 0.05877852522924733em -0.08090169943749474em black,
+ 0.04067366430758001em -0.09135454576426011em black,
+ 0.02079116908177593em -0.09781476007338058em black,
+ 1.2246467991473533e-17em -0.1em black,
+ -0.020791169081775907em -0.09781476007338058em black,
+ -0.04067366430757999em -0.09135454576426011em black,
+ -0.05877852522924731em -0.08090169943749476em black,
+ -0.07431448254773941em -0.06691306063588585em black,
+ -0.08660254037844384em -0.050000000000000044em black,
+ -0.09510565162951536em -0.030901699437494757em black,
+ -0.09945218953682733em -0.010452846326765424em black,
+ -0.09945218953682733em 0.010452846326765387em black,
+ -0.09510565162951537em 0.030901699437494726em black,
+ -0.08660254037844387em 0.05000000000000002em black,
+ -0.07431448254773941em 0.06691306063588585em black,
+ -0.05877852522924734em 0.08090169943749474em black,
+ -0.040673664307580015em 0.09135454576426011em black,
+ -0.020791169081775987em 0.09781476007338057em black;
}