diff --git a/files/file.go b/files/file.go index e0fdb162..164f9fc5 100644 --- a/files/file.go +++ b/files/file.go @@ -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())) } } diff --git a/frontend/src/components/files/VideoPlayer.vue b/frontend/src/components/files/VideoPlayer.vue index e1b18a27..4f96e993 100644 --- a/frontend/src/components/files/VideoPlayer.vue +++ b/frontend/src/components/files/VideoPlayer.vue @@ -6,14 +6,6 @@ style="width: 100%; height: 100%" > -

Sorry, your browser doesn't support embedded videos, but don't worry, you can download it @@ -22,64 +14,81 @@ - diff --git a/frontend/src/utils/subtitle.ts b/frontend/src/utils/subtitle.ts new file mode 100644 index 00000000..dfeaeca3 --- /dev/null +++ b/frontend/src/utils/subtitle.ts @@ -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 = {}; + // font tags -> ::cue span tags + subtitles = subtitles.replace( + /([\s\S]*?)<\/font>/g, + function (_match, color, text) { + const key = + "c_" + color.replace(/^rgb/, "").replace(/\W/g, "").toLowerCase(); + colorMap[key] = color; + return `${text.replace("\n", "").trim()}`; + } + ); + subtitles = subtitles.replace(//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" }); +}