Skip to content

Commit

Permalink
feat: Tooltip in UI
Browse files Browse the repository at this point in the history
* Basic tooltip

* Color changed

* Added forward button

* Updated docs
  • Loading branch information
matvp91 authored Sep 7, 2024
1 parent 8d2400d commit c460a15
Show file tree
Hide file tree
Showing 14 changed files with 398 additions and 164 deletions.
90 changes: 90 additions & 0 deletions packages/docs/features/stitcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,93 @@ Stitcher is a playlist manipulator that can insert HLS interstitials on-the-fly.
::: info
Stitcher is in alpha, until we have proper documentation for this, refer to the source for more info and the API contract. Get in touch if you have questions!
:::

## Create a session

Each playout to a viewer can be considered a session.

```sh [shell]
curl -X POST https://stitcher.domain.com/session
-H "Content-Type: application/json"
-d "{body}"
```

A minimal body payload may look like this:

```json
{
"assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689"
}
```

Behind the scenes, stitcher will create a session and return you a personalised playlist url. Each session is identifiable by a randomly generated uuid. In the example below, we got back a new session with id `44220f14-ffdd-4cfa-a67f-62ef421b4460`. As all we did was create a session with an `assetId`, the resulting master playlist will only cover that asset. Scroll further down if you'd like to extend the session with ads or a bumper.

```json
{
"url": "https://stitcher.domain.com/session/44220f14-ffdd-4cfa-a67f-62ef421b4460/master.m3u8"
}
```

## Playlist manipulation

### Limit resolution

```json
{
"assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689",
"maxResolution": 480
}
```

## Interstitials

### Manual

Let's say you transcoded and packaged a new asset with the id `abbda878-8e08-40f6-ac8b-3507f263450a`. The example below will add it as an interstitial. An HLS interstitials supported player will then switch to the new asset at position `10` and when finished, it'll go back to the main master playlist.

```json
{
"assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689",
"interstitials": [
{
"timeOffset": 10,
"assetId": "abbda878-8e08-40f6-ac8b-3507f263450a"
}
]
}
```

### Bumper

You can manually add a bumper at timeOffset 0 but it is advised to use the `bumperAssetId` option instead.

```json
{
"assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689",
"bumperAssetId": "abbda878-8e08-40f6-ac8b-3507f263450a"
}
```

::: info
When you use both vmapUrl and bumperAssetId, it'll add the bumper interstitial as the last asset in a preroll (starting from 0).
:::

### VMAP

Instruct stitcher to add interstitials based on VMAP definitions. Each VMAP contains one or more `AdBreak` elements with a position of where the interstitial should be.

```json
{
"assetId": "f7e89553-0d3b-4982-ba7b-3ce5499ac689",
"vmapUrl": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpost&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator="
}
```

1. Stitcher will fetch the VMAP. Parses, resolves and flattens each corresponding VAST.
2. For each ad that has not yet been transcoded, it'll start a transcode and package job with sane defaults.
- Each transcode or package job responsible for an ad is tagged with `ad` and can be observed in the dashboard.
3. For each ad that is available, it'll add an interstitial for playback.

::: warning
Ad impressions are not tracked yet, we'd eventually like to provide a client wrapper that tracks ads in a certified manner.
:::
2 changes: 2 additions & 0 deletions packages/hlsjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ Using https://www.svgrepo.com/collection/solar-broken-line-icons for icons.
- When interstitials startTime is beyond the end of the content duration, there are stalls sometimes and content stops buffering. This can be replicated manually when setting interstitials further away.

- Selecting audio and quality during interstitials works for the main asset, but subtitles not.

- Seeking to `this.getInterstitialsManager_().integrated.duration` does not work properly.
80 changes: 56 additions & 24 deletions packages/hlsjs/lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,10 @@ export type HlsAudioTrack = {
playlist: MediaPlaylist;
};

export type HlsSeekRange = {
start: number;
end: number;
};

export type HlsState = {
playheadState: "idle" | "play" | "pause";
playheadState: "idle" | "play" | "pause" | "ended";
time: number;
seekRange: HlsSeekRange;
duration: number;
interstitial: HlsInterstitial | null;
cuePoints: number[];
qualities: HlsQuality[];
Expand All @@ -49,13 +44,44 @@ export type HlsFacadeEvent = {
"*": () => void;
};

const defaultState: HlsState = {
playheadState: "idle",
time: 0,
duration: 0,
interstitial: null,
cuePoints: [],
qualities: [],
autoQuality: false,
subtitleTracks: [],
audioTracks: [],
};

export class HlsFacade extends EventEmitter<HlsFacadeEvent> {
private intervalId_: number | undefined;

constructor(public hls: Hls) {
super();

const { media } = hls;
if (!media) {
throw new HlsFacadeNoMedia();
}

hls.on(Hls.Events.BUFFER_RESET, () => {
media.removeAttribute("autoplay");
this.setState_({ $set: defaultState });
});

media.addEventListener("play", () => this.syncTimings_());

media.addEventListener("seeked", () => this.syncTimings_());

media.addEventListener("playing", () => this.syncTimings_(true));

hls.once(Hls.Events.BUFFER_CREATED, () => {
this.setState_({
autoQuality: { $set: this.hls.autoLevelEnabled },
});
this.syncTimings_();
});

Expand Down Expand Up @@ -108,23 +134,22 @@ export class HlsFacade extends EventEmitter<HlsFacadeEvent> {
},
});
});

hls.on(Hls.Events.MEDIA_ENDED, () => {
this.syncTimings_(false);

this.setState_({
playheadState: { $set: "ended" },
time: { $set: this.state.duration },
});
});
}

destroy() {
clearInterval(this.intervalId_);
}

state: HlsState = {
playheadState: "idle",
time: 0,
seekRange: { start: 0, end: 0 },
interstitial: null,
cuePoints: [],
qualities: [],
autoQuality: this.hls.autoLevelEnabled,
subtitleTracks: [],
audioTracks: [],
};
state: HlsState = defaultState;

playOrPause() {
const media = this.getMedia_();
Expand Down Expand Up @@ -168,8 +193,8 @@ export class HlsFacade extends EventEmitter<HlsFacadeEvent> {
const nextState = update(this.state, spec);
if (nextState !== this.state) {
this.state = nextState;
this.emit("*");
}
this.emit("*");
}

private getInterstitialsManager_() {
Expand All @@ -189,20 +214,23 @@ export class HlsFacade extends EventEmitter<HlsFacadeEvent> {
return media;
}

private syncTimings_() {
private syncTimings_(repeat?: boolean) {
clearInterval(this.intervalId_);

const onTick = () => {
const { integrated } = this.getInterstitialsManager_();

this.setState_({
time: { $set: integrated.currentTime },
seekRange: { $merge: { start: 0, end: integrated.duration } },
time: { $set: precise(integrated.currentTime) },
duration: { $set: precise(integrated.duration) },
});
};

this.intervalId_ = setInterval(onTick, 500);
onTick();
if (repeat === undefined) {
onTick();
} else if (repeat === true) {
this.intervalId_ = setInterval(onTick, 500);
}
}

private syncQualities_() {
Expand Down Expand Up @@ -259,3 +287,7 @@ export class HlsFacadeNoInterstitialsManager extends Error {
super("No interstitials manager found");
}
}

function precise(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
5 changes: 5 additions & 0 deletions packages/hlsjs/lib/ui/components/Controls.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@
.mix-controls-progress {
display: flex;
}

.mix-controls-progress-container {
flex-grow: 1;
margin: 0 1.5em;
}
37 changes: 29 additions & 8 deletions packages/hlsjs/lib/ui/components/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import PlayIcon from "../icons/play.svg?react";
import PauseIcon from "../icons/pause.svg?react";
import SettingsIcon from "../icons/settings.svg?react";
import SubtitlesIcon from "../icons/subtitles.svg?react";
import ForwardIcon from "../icons/forward.svg?react";
import { useVisible } from "../hooks/useVisible";
import { Settings } from "./Settings";
import { SqButton } from "./SqButton";
import { useSettings } from "../hooks/useSettings";
import { TimeStat } from "./TimeStat";
import { useTime } from "../hooks/useTime";
import type { HlsState, HlsFacade } from "../../main";

type ControlsProps = {
Expand All @@ -29,6 +31,8 @@ export function Controls({ facade, state }: ControlsProps) {
controlsVisible = true;
}

const [time, setTargetTime] = useTime(state.time);

return (
<>
<div
Expand All @@ -40,19 +44,36 @@ export function Controls({ facade, state }: ControlsProps) {
>
{showSeekbar(state) ? (
<div className="mix-controls-progress">
<Progress
state={state}
onSeeked={(time) => {
facade.seekTo(time);
}}
/>
<TimeStat state={state} />
<div className="mix-controls-progress-container">
<Progress
time={time}
state={state}
onSeeked={(time) => {
setTargetTime(time);
facade.seekTo(time);
}}
/>
</div>
<TimeStat time={time} state={state} />
</div>
) : null}
<div className="mix-controls-bottom">
<SqButton onClick={() => facade.playOrPause()}>
<SqButton
onClick={() => {
facade.playOrPause();
nudge();
}}
>
{state.playheadState === "play" ? <PauseIcon /> : <PlayIcon />}
</SqButton>
<SqButton
onClick={() => {
facade.seekTo(time + 10);
nudge();
}}
>
<ForwardIcon />
</SqButton>
<div className="mix-controls-gutter" />
<SqButton
onClick={() => setSettingsMode("text-audio")}
Expand Down
Loading

0 comments on commit c460a15

Please sign in to comment.