6 changed files with 334 additions and 32 deletions
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
import Hls from "hls.js"; |
||||
import { useEffect, useMemo, useRef } from "react"; |
||||
|
||||
import { ThumbnailImage } from "@/stores/player/slices/thumbnails"; |
||||
import { usePlayerStore } from "@/stores/player/store"; |
||||
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities"; |
||||
|
||||
class ThumnbnailWorker { |
||||
interrupted: boolean; |
||||
|
||||
videoEl: HTMLVideoElement | null = null; |
||||
|
||||
canvasEl: HTMLCanvasElement | null = null; |
||||
|
||||
hls: Hls | null = null; |
||||
|
||||
cb: (img: ThumbnailImage) => void; |
||||
|
||||
constructor(ops: { addImage: (img: ThumbnailImage) => void }) { |
||||
this.cb = ops.addImage; |
||||
this.interrupted = false; |
||||
} |
||||
|
||||
start(source: LoadableSource) { |
||||
const el = document.createElement("video"); |
||||
const canvas = document.createElement("canvas"); |
||||
this.hls = new Hls(); |
||||
if (source.type === "mp4") { |
||||
el.src = source.url; |
||||
el.crossOrigin = "anonymous"; |
||||
} else if (source.type === "hls") { |
||||
this.hls.attachMedia(el); |
||||
this.hls.loadSource(source.url); |
||||
} else throw new Error("Invalid loadable source type"); |
||||
this.videoEl = el; |
||||
this.canvasEl = canvas; |
||||
this.begin().catch((err) => console.error(err)); |
||||
} |
||||
|
||||
destroy() { |
||||
this.hls?.detachMedia(); |
||||
this.hls?.destroy(); |
||||
this.hls = null; |
||||
this.interrupted = true; |
||||
this.videoEl = null; |
||||
this.canvasEl = null; |
||||
} |
||||
|
||||
private async initVideo() { |
||||
if (!this.videoEl || !this.canvasEl) return; |
||||
await new Promise((resolve, reject) => { |
||||
this.videoEl?.addEventListener("loadedmetadata", resolve); |
||||
this.videoEl?.addEventListener("error", reject); |
||||
}); |
||||
if (!this.videoEl || !this.canvasEl) return; |
||||
this.canvasEl.height = this.videoEl.videoHeight; |
||||
this.canvasEl.width = this.videoEl.videoWidth; |
||||
} |
||||
|
||||
private async takeSnapshot(at: number) { |
||||
if (!this.videoEl || !this.canvasEl) return; |
||||
this.videoEl.currentTime = at; |
||||
await new Promise((resolve) => { |
||||
this.videoEl?.addEventListener("seeked", resolve); |
||||
}); |
||||
if (!this.videoEl || !this.canvasEl) return; |
||||
const ctx = this.canvasEl.getContext("2d"); |
||||
if (!ctx) return; |
||||
ctx.drawImage( |
||||
this.videoEl, |
||||
0, |
||||
0, |
||||
this.canvasEl.width, |
||||
this.canvasEl.height |
||||
); |
||||
const imgUrl = this.canvasEl.toDataURL(); |
||||
this.cb({ |
||||
at, |
||||
data: imgUrl, |
||||
}); |
||||
} |
||||
|
||||
private async begin() { |
||||
const vid = this.videoEl; |
||||
if (!vid) return; |
||||
await this.initVideo(); |
||||
if (this.interrupted) return; |
||||
await this.takeSnapshot(vid.duration / 2); |
||||
} |
||||
} |
||||
|
||||
export function ThumbnailScraper() { |
||||
const addImage = usePlayerStore((s) => s.thumbnails.addImage); |
||||
const source = usePlayerStore((s) => s.source); |
||||
const workerRef = useRef<ThumnbnailWorker | null>(null); |
||||
|
||||
const inputStream = useMemo(() => { |
||||
if (!source) return null; |
||||
return selectQuality(source, { |
||||
automaticQuality: false, |
||||
lastChosenQuality: "360", |
||||
}); |
||||
}, [source]); |
||||
|
||||
// TODO stop worker on meta change
|
||||
|
||||
// start worker with the stream
|
||||
useEffect(() => { |
||||
// dont interrupt existing working
|
||||
if (workerRef.current) return; |
||||
if (!inputStream) return; |
||||
const ins = new ThumnbnailWorker({ |
||||
addImage, |
||||
}); |
||||
workerRef.current = ins; |
||||
ins.start(inputStream.stream); |
||||
}, [inputStream, addImage]); |
||||
|
||||
// destroy worker on unmount
|
||||
useEffect(() => { |
||||
return () => { |
||||
if (workerRef.current) workerRef.current.destroy(); |
||||
}; |
||||
}, []); |
||||
|
||||
return null; |
||||
} |
@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
|
||||
import { MakeSlice } from "@/stores/player/slices/types"; |
||||
|
||||
export interface ThumbnailImage { |
||||
at: number; |
||||
data: string; |
||||
} |
||||
|
||||
export interface ThumbnailSlice { |
||||
thumbnails: { |
||||
images: ThumbnailImage[]; |
||||
addImage(img: ThumbnailImage): void; |
||||
}; |
||||
} |
||||
|
||||
export interface ThumbnailImagePosition { |
||||
index: number; |
||||
image: ThumbnailImage; |
||||
} |
||||
|
||||
/** |
||||
* get nearest image at the timestamp provided |
||||
* @param images images, must be sorted |
||||
*/ |
||||
export function nearestImageAt( |
||||
images: ThumbnailImage[], |
||||
at: number |
||||
): ThumbnailImagePosition | null { |
||||
// no images, early return
|
||||
if (images.length === 0) return null; |
||||
|
||||
const indexPastTimestamp = images.findIndex((v) => v.at < at); |
||||
|
||||
// no image found past timestamp, so last image must be closest
|
||||
if (indexPastTimestamp === -1) |
||||
return { |
||||
index: images.length - 1, |
||||
image: images[images.length - 1], |
||||
}; |
||||
|
||||
const imagePastTimestamp = images[indexPastTimestamp]; |
||||
|
||||
// if past timestamp is first image, just return that image
|
||||
if (indexPastTimestamp === 0) |
||||
return { |
||||
index: indexPastTimestamp, |
||||
image: imagePastTimestamp, |
||||
}; |
||||
|
||||
// distance before distance past
|
||||
// | |
|
||||
// [before] --------------------- [at] --------------------- [past]
|
||||
const imageBeforeTimestamp = images[indexPastTimestamp - 1]; |
||||
const distanceBefore = at - imageBeforeTimestamp.at; |
||||
const distancePast = imagePastTimestamp.at - at; |
||||
|
||||
// if distance of before timestamp is smaller than the distance past
|
||||
// before is closer, return that
|
||||
// [before] --X-------------- [past]
|
||||
if (distanceBefore < distancePast) |
||||
return { |
||||
index: indexPastTimestamp - 1, |
||||
image: imageBeforeTimestamp, |
||||
}; |
||||
|
||||
// must be closer to past here, return past
|
||||
// [before] --------------X-- [past]
|
||||
return { |
||||
index: indexPastTimestamp, |
||||
image: imagePastTimestamp, |
||||
}; |
||||
} |
||||
|
||||
export const createThumbnailSlice: MakeSlice<ThumbnailSlice> = (set, get) => ({ |
||||
thumbnails: { |
||||
images: [], |
||||
addImage(img) { |
||||
const store = get(); |
||||
const exactOrPastImageIndex = store.thumbnails.images.findIndex( |
||||
(v) => v.at <= img.at |
||||
); |
||||
|
||||
// not found past or exact, so just append to the end
|
||||
if (exactOrPastImageIndex === -1) { |
||||
set((s) => { |
||||
s.thumbnails.images.push(img); |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
const exactOrPastImage = store.thumbnails.images[exactOrPastImageIndex]; |
||||
|
||||
// found exact, replace data
|
||||
if (exactOrPastImage.at === img.at) { |
||||
set((s) => { |
||||
s.thumbnails.images[exactOrPastImageIndex] = img; |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
// found one past, insert right before it
|
||||
set((s) => { |
||||
s.thumbnails.images.splice(exactOrPastImageIndex, 0, img); |
||||
}); |
||||
}, |
||||
}, |
||||
}); |
Loading…
Reference in new issue