Add support for srt subtitles in VideoPlayer
This commit is contained in:
parent
070301c917
commit
5aad03d6f6
@ -14,6 +14,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -307,8 +308,23 @@ func (i *FileInfo) detectSubtitles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
base := strings.TrimSuffix(i.Name, ext)
|
base := strings.TrimSuffix(i.Name, ext)
|
||||||
|
subDirsRegex := regexp.MustCompile("(?i)^sub(s|titles)$")
|
||||||
for _, f := range dir {
|
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()))
|
i.Subtitles = append(i.Subtitles, path.Join(parentDir, f.Name()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,6 @@
|
|||||||
style="width: 100%; height: 100%"
|
style="width: 100%; height: 100%"
|
||||||
>
|
>
|
||||||
<source :src="source" />
|
<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">
|
<p class="vjs-no-js">
|
||||||
Sorry, your browser doesn't support embedded videos, but don't worry, you
|
Sorry, your browser doesn't support embedded videos, but don't worry, you
|
||||||
can <a :href="source">download it</a>
|
can <a :href="source">download it</a>
|
||||||
@ -22,64 +14,81 @@
|
|||||||
</video>
|
</video>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
import videojs from "video.js";
|
import videojs from "video.js";
|
||||||
|
import Player from "video.js/dist/types/player";
|
||||||
import "videojs-mobile-ui";
|
import "videojs-mobile-ui";
|
||||||
import "videojs-hotkeys";
|
import "videojs-hotkeys";
|
||||||
|
import { loadSubtitle } from "@/utils/subtitle";
|
||||||
|
|
||||||
import "video.js/dist/video-js.min.css";
|
import "video.js/dist/video-js.min.css";
|
||||||
import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
|
import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
|
||||||
|
|
||||||
export default {
|
const videoPlayer = ref<HTMLElement | null>(null);
|
||||||
name: "VideoPlayer",
|
const player = ref<Player | null>(null);
|
||||||
props: {
|
|
||||||
source: {
|
const props = withDefaults(
|
||||||
type: String,
|
defineProps<{
|
||||||
default() {
|
source: string;
|
||||||
return "";
|
subtitles?: string[];
|
||||||
|
options?: any;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
options: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
player.value = videojs(
|
||||||
|
videoPlayer.value!,
|
||||||
|
{
|
||||||
|
html5: {
|
||||||
|
// needed for customizable subtitles
|
||||||
|
nativeTextTracks: false,
|
||||||
},
|
},
|
||||||
},
|
plugins: {
|
||||||
options: {
|
hotkeys: {
|
||||||
type: Object,
|
volumeStep: 0.1,
|
||||||
default() {
|
seekStep: 10,
|
||||||
return {};
|
enableModifiersForNumbers: false,
|
||||||
},
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
() => {
|
...props.options,
|
||||||
// this.player.log("onPlayerReady", this);
|
},
|
||||||
}
|
// onReady callback
|
||||||
);
|
async () => {
|
||||||
this.player.mobileUi();
|
// player.value!.log("onPlayerReady", this);
|
||||||
},
|
addSubtitles(props.subtitles);
|
||||||
beforeUnmount() {
|
|
||||||
if (this.player) {
|
|
||||||
this.player.dispose();
|
|
||||||
}
|
}
|
||||||
},
|
);
|
||||||
|
// 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>
|
</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