Add support for srt subtitles in VideoPlayer

This commit is contained in:
Kloon ImKloon 2023-10-02 21:32:23 +02:00
parent 070301c917
commit 5aad03d6f6
No known key found for this signature in database
GPG Key ID: CCF1C86A995C5B6A
3 changed files with 154 additions and 57 deletions

View File

@ -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()))
}
}

View File

@ -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>

View 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" });
}