6 changed files with 334 additions and 32 deletions
@ -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 @@ |
|||||||
|
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