Add support for srt subtitles in VideoPlayer
This commit is contained in:
parent
070301c917
commit
5aad03d6f6
@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -307,8 +308,23 @@ func (i *FileInfo) detectSubtitles() {
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(i.Name, ext)
|
||||
subDirsRegex := regexp.MustCompile("(?i)^sub(s|titles)$")
|
||||
for _, f := range dir {
|
||||
if !f.IsDir() && strings.HasPrefix(f.Name(), base) && strings.HasSuffix(f.Name(), ".vtt") {
|
||||
// load all .vtt/.srt subtitles from subs directories
|
||||
if f.IsDir() && subDirsRegex.MatchString(f.Name()) {
|
||||
subsDir := path.Join(parentDir, f.Name())
|
||||
var err error
|
||||
dir, err = afero.ReadDir(i.Fs, subsDir)
|
||||
if err == nil {
|
||||
for _, f := range dir {
|
||||
if !f.IsDir() && (strings.HasSuffix(f.Name(), ".vtt") ||
|
||||
strings.HasSuffix(f.Name(), ".srt")) {
|
||||
i.Subtitles = append(i.Subtitles, path.Join(subsDir, f.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !f.IsDir() && strings.HasPrefix(f.Name(), base) &&
|
||||
(strings.HasSuffix(f.Name(), ".vtt") || strings.HasSuffix(f.Name(), ".srt")) {
|
||||
i.Subtitles = append(i.Subtitles, path.Join(parentDir, f.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,14 +6,6 @@
|
||||
style="width: 100%; height: 100%"
|
||||
>
|
||||
<source :src="source" />
|
||||
<track
|
||||
kind="subtitles"
|
||||
v-for="(sub, index) in subtitles"
|
||||
:key="index"
|
||||
:src="sub"
|
||||
:label="'Subtitle ' + index"
|
||||
:default="index === 0"
|
||||
/>
|
||||
<p class="vjs-no-js">
|
||||
Sorry, your browser doesn't support embedded videos, but don't worry, you
|
||||
can <a :href="source">download it</a>
|
||||
@ -22,64 +14,81 @@
|
||||
</video>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import videojs from "video.js";
|
||||
import Player from "video.js/dist/types/player";
|
||||
import "videojs-mobile-ui";
|
||||
import "videojs-hotkeys";
|
||||
import { loadSubtitle } from "@/utils/subtitle";
|
||||
|
||||
import "video.js/dist/video-js.min.css";
|
||||
import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
|
||||
|
||||
export default {
|
||||
name: "VideoPlayer",
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
default() {
|
||||
return "";
|
||||
const videoPlayer = ref<HTMLElement | null>(null);
|
||||
const player = ref<Player | null>(null);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
source: string;
|
||||
subtitles?: string[];
|
||||
options?: any;
|
||||
}>(),
|
||||
{
|
||||
options: {},
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
player.value = videojs(
|
||||
videoPlayer.value!,
|
||||
{
|
||||
html5: {
|
||||
// needed for customizable subtitles
|
||||
nativeTextTracks: false,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
subtitles: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
player: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.player = videojs(
|
||||
this.$refs.videoPlayer,
|
||||
{
|
||||
...this.options,
|
||||
plugins: {
|
||||
hotkeys: {
|
||||
volumeStep: 0.1,
|
||||
seekStep: 10,
|
||||
enableModifiersForNumbers: false,
|
||||
},
|
||||
plugins: {
|
||||
hotkeys: {
|
||||
volumeStep: 0.1,
|
||||
seekStep: 10,
|
||||
enableModifiersForNumbers: false,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
// this.player.log("onPlayerReady", this);
|
||||
}
|
||||
);
|
||||
this.player.mobileUi();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
...props.options,
|
||||
},
|
||||
// onReady callback
|
||||
async () => {
|
||||
// player.value!.log("onPlayerReady", this);
|
||||
addSubtitles(props.subtitles);
|
||||
}
|
||||
},
|
||||
);
|
||||
// TODO: need to test on mobile
|
||||
// @ts-ignore
|
||||
player.value!.mobileUi();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (player.value) {
|
||||
player.value.dispose();
|
||||
player.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const addSubtitles = async (subtitles: string[] | undefined) => {
|
||||
if (!subtitles) return;
|
||||
// add subtitles dynamically (srt is converted on-the-fly)
|
||||
const subs = await Promise.all(
|
||||
subtitles.map(async (s) => await loadSubtitle(s))
|
||||
);
|
||||
// TODO: player.value wouldnt work here, no idea why
|
||||
const _player = videojs.getPlayer(videoPlayer.value!);
|
||||
for (const [idx, sub] of subs.filter((s) => !!s.src).entries()) {
|
||||
_player.addRemoteTextTrack({
|
||||
src: sub.src,
|
||||
label: sub.label,
|
||||
kind: "subtitles",
|
||||
default: idx === 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
72
frontend/src/utils/subtitle.ts
Normal file
72
frontend/src/utils/subtitle.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { StatusError } from "@/api/utils";
|
||||
|
||||
export async function loadSubtitle(subUrl: string) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(subUrl);
|
||||
} catch (_) {
|
||||
// treat it as a relative url
|
||||
// we only need this for filename
|
||||
url = new URL(subUrl, window.location.origin);
|
||||
}
|
||||
|
||||
const label = decodeURIComponent(
|
||||
url.pathname
|
||||
.split("/")
|
||||
.pop()!
|
||||
.replace(/\.[^/.]+$/, "")
|
||||
);
|
||||
let src;
|
||||
if (url.pathname.toLowerCase().endsWith(".srt")) {
|
||||
try {
|
||||
const resp = await fetch(subUrl);
|
||||
if (!resp.ok) {
|
||||
throw new StatusError(
|
||||
`Failed to fetch subtitle from ${subUrl}!`,
|
||||
resp.status
|
||||
);
|
||||
}
|
||||
const vtt = srtToVttBlob(await resp.text());
|
||||
src = URL.createObjectURL(vtt);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else if (url.pathname.toLowerCase().endsWith(".vtt")) {
|
||||
src = subUrl;
|
||||
}
|
||||
return { src, label };
|
||||
}
|
||||
|
||||
export function srtToVttBlob(srtData: string) {
|
||||
const VTT_HEAD = "WEBVTT\n\n";
|
||||
// Replace line breaks with \n
|
||||
let subtitles = srtData.replace(/\r\n|\r|\n/g, "\n");
|
||||
// commas -> dots in timestamps
|
||||
subtitles = subtitles.replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g, "$1.$2");
|
||||
// map SRT font colors to VTT cue span classes
|
||||
const colorMap: Record<string, string> = {};
|
||||
// font tags -> ::cue span tags
|
||||
subtitles = subtitles.replace(
|
||||
/<font color="([^"]+)">([\s\S]*?)<\/font>/g,
|
||||
function (_match, color, text) {
|
||||
const key =
|
||||
"c_" + color.replace(/^rgb/, "").replace(/\W/g, "").toLowerCase();
|
||||
colorMap[key] = color;
|
||||
return `<c.${key}>${text.replace("\n", "").trim()}</c>`;
|
||||
}
|
||||
);
|
||||
subtitles = subtitles.replace(/<br\s*\/?>/g, "\n");
|
||||
let vttSubtitles = VTT_HEAD;
|
||||
if (Object.keys(colorMap).length) {
|
||||
let vttStyles = "";
|
||||
for (const cssClass in colorMap) {
|
||||
const color = colorMap[cssClass];
|
||||
// add cue style declaration
|
||||
vttStyles += `::cue(.${cssClass}) {color: ${color};}\n`;
|
||||
}
|
||||
vttSubtitles += vttStyles;
|
||||
}
|
||||
vttSubtitles += "\n"; // an empty line MUST separate styles from subs
|
||||
vttSubtitles += subtitles;
|
||||
return new Blob([vttSubtitles], { type: "text/vtt" });
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user