From 6f66f0641ba66d8a12c65cafe5ddbcc31280aa54 Mon Sep 17 00:00:00 2001 From: Sean McBroom Date: Tue, 4 Jun 2024 20:30:20 -0500 Subject: [PATCH 01/31] feat(subtitles): settings & custom element for external subtitles --- frontend/locales/en.json | 5 + .../src/components/Playback/PlayerElement.vue | 2 + .../src/components/Playback/SubtitleTrack.vue | 88 ++++++++ frontend/src/pages/settings/index.vue | 2 +- frontend/src/pages/settings/subtitles.vue | 119 +++++++++++ frontend/src/store/client-settings.ts | 66 +++++- frontend/src/store/player-element.ts | 189 ++++++++++++++---- frontend/src/utils/subtitles.ts | 162 +++++++++++++++ frontend/types/global/components.d.ts | 1 + frontend/types/global/routes.d.ts | 1 + 10 files changed, 589 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/Playback/SubtitleTrack.vue create mode 100644 frontend/src/pages/settings/subtitles.vue create mode 100644 frontend/src/utils/subtitles.ts diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 406a64c1857..4172343d738 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -114,6 +114,7 @@ "filtersNotFound": "Unable to load filters", "finish": "Finish", "followSystemTheme": "Follow system theme", + "fontSize": "Font Size", "fullScreen": "Full screen", "general": "General", "genericJellyfinPlaceholderDevice": "Generic Jellyfin device", @@ -266,6 +267,7 @@ "playinginShuffle": "Playing in shuffle", "plugins": "Plugins", "pluginsSettingsDescription": "Add and configure new features for this server", + "positionFromBottom": "Position From Bottom", "poweredByJellyfin": "This server is powered by Jellyfin", "preferredLanguage": "Preferred language", "preferredMetadataLanguage": "Preferred metadata language", @@ -353,7 +355,10 @@ "startNow": "Start now", "status": "Status", "stretch": "Stretch", + "stroke": "Stroke", "studios": "Studios", + "subtitleFont": "Subtitle Font", + "subtitlePreviewText": "This is a preview of subtitles \n on this device.", "subtitles": "Subtitles", "subtitlesSettingsDescription": "Control how subtitles are displayed on this device", "switchToDarkMode": "Switch to dark mode", diff --git a/frontend/src/components/Playback/PlayerElement.vue b/frontend/src/components/Playback/PlayerElement.vue index e2f9ace2a7d..e07a3eff947 100644 --- a/frontend/src/components/Playback/PlayerElement.vue +++ b/frontend/src/components/Playback/PlayerElement.vue @@ -23,6 +23,8 @@ :srclang="sub.srcLang" :src="sub.src"> + diff --git a/frontend/src/components/Playback/SubtitleTrack.vue b/frontend/src/components/Playback/SubtitleTrack.vue new file mode 100644 index 00000000000..93b859a0d24 --- /dev/null +++ b/frontend/src/components/Playback/SubtitleTrack.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/pages/settings/index.vue b/frontend/src/pages/settings/index.vue index 7672526e9fd..10618bb560b 100644 --- a/frontend/src/pages/settings/index.vue +++ b/frontend/src/pages/settings/index.vue @@ -186,7 +186,7 @@ const userItems = computed(() => { icon: IMdiSubtitles, name: t('subtitles'), description: t('subtitlesSettingsDescription'), - link: undefined + link: '/settings/subtitles' } ]; }); diff --git a/frontend/src/pages/settings/subtitles.vue b/frontend/src/pages/settings/subtitles.vue new file mode 100644 index 00000000000..2f5cab3bbfd --- /dev/null +++ b/frontend/src/pages/settings/subtitles.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/src/store/client-settings.ts b/frontend/src/store/client-settings.ts index 18ed744f836..11910b110ee 100644 --- a/frontend/src/store/client-settings.ts +++ b/frontend/src/store/client-settings.ts @@ -2,21 +2,30 @@ import { useNavigatorLanguage, usePreferredDark, watchImmediate } from '@vueuse/core'; -import { computed, watch } from 'vue'; +import { computed, watch, type CSSProperties } from 'vue'; import { i18n } from '@/plugins/i18n'; import { remote } from '@/plugins/remote'; import { vuetify } from '@/plugins/vuetify'; import { sealed } from '@/utils/validation'; import { SyncedStore } from '@/store/super/synced-store'; +import { FALLBACK_SUBTITLE_FONT, SUBTITLE_FONT_FAMILIES } from '@/utils/subtitles'; /** * == INTERFACES AND TYPES == * Casted typings for the CustomPrefs property of DisplayPreferencesDto */ +export type subtitleFontFamily = typeof SUBTITLE_FONT_FAMILIES[number]; export interface ClientSettingsState { darkMode: 'auto' | boolean; locale: string; + subtitleAppearance: { + fontFamily: subtitleFontFamily; + fontSize: number; + positionFromBottom: number; + backdrop: boolean; + stroke: boolean; + }; } @sealed @@ -52,6 +61,52 @@ class ClientSettingsStore extends SyncedStore { return this._state.darkMode; } + public set subtitleAppearance(newVal: ClientSettingsState['subtitleAppearance']) { + this._state.subtitleAppearance = newVal; + } + + public get subtitleAppearance(): ClientSettingsState['subtitleAppearance'] { + return this._state.subtitleAppearance; + } + + /** + * CSS Style Properties for subtitles + */ + public get subtitleStyle(): CSSProperties { + return { + fontFamily: `${this.subtitleAppearance.fontFamily}, ${FALLBACK_SUBTITLE_FONT} !important`, + fontSize: `${this.subtitleAppearance.fontSize}em`, + marginBottom: `${this.subtitleAppearance.positionFromBottom}vh`, + backgroundColor: this.subtitleAppearance.backdrop ? 'rgba(0, 0, 0, 0.5)' : 'transparent', + padding: '10px', + color: 'white', + /** + * If stroke is enabled we use the textShadow property + * to create a stroke outline around the text + */ + textShadow: this.subtitleAppearance.stroke + ? ` + 4px 0 0 black, + 3.6956px 1.5308px 0 black, + 2.8284px 2.8284px 0 black, + 1.5308px 3.6956px 0 black, + 0 4px 0 black, + -1.5308px 3.6956px 0 black, + -2.8284px 2.8284px 0 black, + -3.6956px 1.5308px 0 black, + -4px 0 0 black, + -3.6956px -1.5308px 0 black, + -2.8284px -2.8284px 0 black, + -1.5308px -3.6956px 0 black, + 0 -4px 0 black, + 1.5308px -3.6956px 0 black, + 2.8284px -2.8284px 0 black, + 3.6956px -1.5308px 0 black, + 2px 2px 15px black !important` + : undefined + } + } + public readonly currentTheme = computed(() => { const dark = 'dark'; const light = 'light'; @@ -73,7 +128,14 @@ class ClientSettingsStore extends SyncedStore { public constructor() { super('clientSettings', { darkMode: 'auto', - locale: 'auto' + locale: 'auto', + subtitleAppearance: { + fontFamily: SUBTITLE_FONT_FAMILIES[0], + fontSize: 1.5, + positionFromBottom: 10, + backdrop: true, + stroke: false + } }, 'localStorage'); /** * == WATCHERS == diff --git a/frontend/src/store/player-element.ts b/frontend/src/store/player-element.ts index fb7781d7f5b..9ce7d97e438 100644 --- a/frontend/src/store/player-element.ts +++ b/frontend/src/store/player-element.ts @@ -10,18 +10,26 @@ import jassubWasmUrl from 'jassub/dist/jassub-worker.wasm?url'; import { PgsRenderer } from 'libpgs'; import pgssubWorker from 'libpgs/dist/libpgs.worker.js?url'; import { computed, nextTick, shallowRef, watch } from 'vue'; -import { playbackManager } from './playback-manager'; +import { SubtitleDeliveryMethod } from '@jellyfin/sdk/lib/generated-client/models/subtitle-delivery-method'; +import { playbackManager, type PlaybackExternalTrack } from './playback-manager'; import { isArray, isNil, sealed } from '@/utils/validation'; import { mediaElementRef } from '@/store'; import { CommonStore } from '@/store/super/common-store'; import { router } from '@/plugins/router'; import { remote } from '@/plugins/remote'; +import { isMobile } from '@/utils/browser-detection'; +import { parseSsaFile, parseVttFile, type ParsedSubtitleTrack } from '@/utils/subtitles'; + +interface SubtitleExternalTrack extends PlaybackExternalTrack { + parsed?: ParsedSubtitleTrack; +} /** * == INTERFACES AND TYPES == */ interface PlayerElementState { isStretched: boolean; + currentExternalSubtitleTrack?: SubtitleExternalTrack; } export const videoContainerRef = shallowRef(); @@ -47,6 +55,39 @@ class PlayerElementStore extends CommonStore { } }); + public get currentExternalSubtitleTrack(): PlayerElementState['currentExternalSubtitleTrack'] { + return this._state.currentExternalSubtitleTrack; + } + + private set currentExternalSubtitleTrack(newVal: PlayerElementState['currentExternalSubtitleTrack']) { + this._state.currentExternalSubtitleTrack = newVal; + } + + private get _usingExternalVttSubtitles(): boolean { + return !isNil(playbackManager.currentSubtitleTrack) + && playbackManager.currentSubtitleTrack.DeliveryMethod === SubtitleDeliveryMethod.External + && playbackManager.currentSubtitleTrack.Codec !== 'ass' + && playbackManager.currentSubtitleTrack.Codec !== 'ssa'; + } + + private get _usingExternalSsaSubtitles(): boolean { + return !isNil(playbackManager.currentSubtitleTrack) + && playbackManager.currentSubtitleTrack.DeliveryMethod === SubtitleDeliveryMethod.External + && (playbackManager.currentSubtitleTrack.Codec === 'ssa' || playbackManager.currentSubtitleTrack.Codec === 'ass'); + } + + /** + * Logic for applying custom subtitle track. + * + * Returns flase if subtitle devliery method isn't external + * or if device is iOS/Android. + */ + private get _useCustomSubtitleTrack(): boolean { + return !isNil(playbackManager.currentSubtitleTrack) + && playbackManager.currentSubtitleTrack.DeliveryMethod === SubtitleDeliveryMethod.External + && !isMobile(); + } + /** * == ACTIONS == */ @@ -111,7 +152,6 @@ class PlayerElementStore extends CommonStore { private readonly _setPgsTrack = (trackSrc: string): void => { if ( !this._pgssub - && mediaElementRef.value && mediaElementRef.value instanceof HTMLVideoElement ) { this._pgssub = new PgsRenderer({ @@ -133,50 +173,112 @@ class PlayerElementStore extends CommonStore { }; /** - * Applies the current subtitle from the playbackManager store - * - * It first disables all the VTT and SSA subtitles - * It then find the potential index of the applied VTT sub - * Or the PlaybackExternalTrack object of the potential SSA sub - * - * If external and VTT, it shows the correct one - * If external and SSA, it loads it in SO + * Applies VTT (WebVTT) subtitles to the media element. * - * If embedded, a new transcode is automatically fetched from the playbackManager watchers. + * This function searches for the VTT track associated with the + * currently selected subtitle. */ - public readonly applyCurrentSubtitle = async (): Promise => { - const serverAddress = remote.sdk.api?.basePath; + private readonly _applyVttSubtitles = async (): Promise => { + if (!mediaElementRef.value) { + return; + } + /** - * Finding (if it exists) the VTT or SSA track associated to the newly picked subtitle + * Finding (if it exists) the SSA track associated to the newly picked subtitle */ - const vttIdx = playbackManager.currentItemVttParsedSubtitleTracks.findIndex( - sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex - ); - const ass = playbackManager.currentItemAssParsedSubtitleTracks.find( + const vtt = playbackManager.currentItemVttParsedSubtitleTracks.find( sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex ); - const pgs = playbackManager.currentItemPgsParsedSubtitleTracks.find( + + /** + * If VTT found, applying it + */ + if (vtt?.src && vtt.srcIndex !== -1 && mediaElementRef.value.textTracks[vtt.srcIndex]) { + this.currentExternalSubtitleTrack = vtt; + + /** + * Check if client is able to display custom subtitle track + * otherwise show default subtitle track + */ + if (this._useCustomSubtitleTrack) { + const data = await parseVttFile(vtt.src); + + this.currentExternalSubtitleTrack.parsed = data; + } else { + mediaElementRef.value.textTracks[vtt.srcIndex].mode = 'showing'; + } + } + }; + + /** + * Applies SSA (SubStation Alpha) subtitles to the media element. + * + * This function searches for the SSA track associated with the + * currently selected subtitle. + */ + private readonly _applySsaSubtitles = async (): Promise => { + if (!mediaElementRef.value) { + return; + } + + const serverAddress = remote.sdk.api?.basePath; + + /** + * Finding (if it exists) the ssa track associated to the newly picked subtitle + */ + const ssa = playbackManager.currentItemAssParsedSubtitleTracks.find( sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex ); - const attachedFonts - = playbackManager.currentMediaSource?.MediaAttachments?.filter(a => - this._isSupportedFont(a.MimeType) - ) - .map((a) => { - if (a.DeliveryUrl && serverAddress) { - return `${serverAddress}${a.DeliveryUrl}`; - } - }) - .filter((a): a is string => a !== undefined) ?? []; + /** + * If SSA found, applying it + */ + if (ssa?.src) { + this.currentExternalSubtitleTrack = ssa; + + /** + * Check if client is able to display custom subtitle track + * otherwise use Subtitle Opctopus + */ + if (this._useCustomSubtitleTrack) { + const data = await parseSsaFile(ssa.src); + + this.currentExternalSubtitleTrack.parsed = data; + } else { + const attachedFonts + = playbackManager.currentMediaSource?.MediaAttachments?.filter(a => + this._isSupportedFont(a.MimeType) + ) + .map((a) => { + if (a.DeliveryUrl && serverAddress) { + return `${serverAddress}${a.DeliveryUrl}`; + } + }) + .filter((a): a is string => a !== undefined) ?? []; + + this._setSsaTrack(ssa.src, attachedFonts); + } + } + }; + + /** + * Applies the current subtitle from the playbackManager store + * + * It first disables all the VTT and SSA subtitles + * then filters the streams by codec and passes + * to the function to apply that codec + */ + public readonly applyCurrentSubtitle = async (): Promise => { if (!mediaElementRef.value) { return; } - await nextTick(); + const pgs = playbackManager.currentItemPgsParsedSubtitleTracks.find( + sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex + ); /** - * Disabling VTT and SSA subs at first + * Clear VTT and SSA subs first */ for (const textTrack of mediaElementRef.value.textTracks) { if (textTrack.mode !== 'disabled') { @@ -186,28 +288,29 @@ class PlayerElementStore extends CommonStore { this._freeSsaTrack(); this._freePgsTrack(); + this.currentExternalSubtitleTrack = undefined; - if (vttIdx !== -1 && mediaElementRef.value.textTracks[vttIdx]) { - /** - * If VTT found, applying it - */ - mediaElementRef.value.textTracks[vttIdx].mode = 'showing'; - } else if (ass?.src) { - /** - * If SSA, using Subtitle Opctopus - */ - this._setSsaTrack(ass.src, attachedFonts); - } else if (pgs?.src) { + await nextTick(); + + /** + * Check which subtitle codec is being used and apply + */ + if (pgs?.src) { /** * If PGS, using libpgs to render */ this._setPgsTrack(pgs.src); + } else if (this._usingExternalVttSubtitles) { + await this._applyVttSubtitles(); + } else if (this._usingExternalSsaSubtitles) { + await this._applySsaSubtitles(); } }; public constructor() { super('playerElement', { - isStretched: false + isStretched: false, + currentExternalSubtitleTrack: undefined }); /** diff --git a/frontend/src/utils/subtitles.ts b/frontend/src/utils/subtitles.ts new file mode 100644 index 00000000000..fd3f0d5b82f --- /dev/null +++ b/frontend/src/utils/subtitles.ts @@ -0,0 +1,162 @@ +/** + * Helper for subtitle manipulation and subtitle-related utility functions + */ + +interface Subtitle { + start: number; + end: number; + text: string; +} + +export type ParsedSubtitleTrack = Subtitle[] + +export const SUBTITLE_FONT_FAMILIES = [ + 'Figtree Variable', + 'Trebuchet MS', + 'Verdana', + 'Sans Serif MS', + 'Arial', + 'Courier New', + 'Times New Roman', + 'Old English Text MT', + 'Century Gothic', + 'Helvetica', + 'Garamond' +] as const; + +export const FALLBACK_SUBTITLE_FONT = 'sans-serif, system-ui'; + +/** + * Parse time string used in subtitle files to seconds + */ +function parseTime(timeString: string) { + const [hours, minutes, seconds] = timeString.split(':').map(Number.parseFloat); + return hours * 3600 + minutes * 60 + seconds; +} + +/** +* Parses a VTT (WebVTT) file from a given URL +* Extracts dialogue lines with start and end times, and text content. +* +* Converts specific tags to styled tags, +* and sanitizes the content by removing harmful tags. +*/ +export async function parseVttFile(src: string) { + return fetch(src) + .then(response => response.text()) + .then((vttText) => { + try { + const subtitles: ParsedSubtitleTrack = []; + const vttLines = vttText.split('\n'); + + let i = 0; + while (i < vttLines.length) { + // Skip empty lines + if (vttLines[i].trim() === '') { + i++; + continue; + } + + if (vttLines[i].includes('-->')) { + const [start, end] = vttLines[i].split(' --> '); + let text = ''; + i++; + while (i < vttLines.length && !vttLines[i].includes('-->')) { + text += vttLines[i] + '\n'; + i++; + } + + // Replace tags with html elements & remove harmful tags to sanitize + const sanitizedText = text + .replace(//g, '') + .replace(/<\/i>/g, '') + .replace(//g, '') + .replace(/<\/b>/g, '') + .replace(//g, '') + .replace(/<\/u>/g, '') + .replace(//g, '') + .replace(/<\/em>/g, '') + .replace(//g, '') + .replace(/<\/strong>/g, '') + .replace(//g, '') + .replace(/<\/mark>/g, '') + .replace(/.*?<\/script>/g, '') + .replace(/.*?<\/iframe>/g, '') + .replace(/.*?<\/object>/g, '') + .replace(/.*?<\/embed>/g, '') + .replace(/.*?<\/style>/g, ''); + + subtitles.push({ + start: parseTime(start), + end: parseTime(end), + text: sanitizedText.trim() + }); + } else { + i++; + } + } + + return subtitles; + } catch (err) { + console.error("error parsing VTT subtitles", err); + } + }); + } + +/** +* Parses an ASS/SSA (SubStation Alpha) file from a given URL. +* Extracts dialogue lines with start and end times, and text content. +* +* Converts specific tags to styled tags, +* and sanitizes the content by removing harmful tags. +*/ +export async function parseSsaFile(src: string) { + return await fetch(src) + .then(res => res.text()) + .then(ssaText => { + try { + const subtitles: ParsedSubtitleTrack = []; + const ssaLines = ssaText.split('\n'); + + let i = 0; + while (i < ssaLines.length) { + const line = ssaLines[i].trim(); + + if (line.startsWith('Dialogue:')) { + const dialogueParts = line.split(','); + const timeStart = dialogueParts[1].trim(); + const timeEnd = dialogueParts[2].trim(); + const text = dialogueParts.slice(9).join(',').trim(); + + // Replace tags with HTML elements & remove harmful tags to sanitize + const sanitizedText = text + .replace(/{\\i1}/g, '') + .replace(/{\\i0}/g, '') + .replace(/{\\b1}/g, '') + .replace(/{\\b0}/g, '') + .replace(/{\\u1}/g, '') + .replace(/{\\u0}/g, '') + .replace(/{.*?}/g, '') + .replace(/.*?<\/script>/gi, '') + .replace(/.*?<\/iframe>/gi, '') + .replace(/.*?<\/object>/gi, '') + .replace(/.*?<\/embed>/gi, '') + .replace(/.*?<\/style>/gi, ''); + + subtitles.push({ + start: parseTime(timeStart), + end: parseTime(timeEnd), + text: sanitizedText.trim(), + }); + } + + i++; + } + + return subtitles; + } catch (err) { + console.error('Error parsing ASS subtitles', err); + return []; + } + }); +} \ No newline at end of file diff --git a/frontend/types/global/components.d.ts b/frontend/types/global/components.d.ts index adc8896807e..a9579b8db59 100644 --- a/frontend/types/global/components.d.ts +++ b/frontend/types/global/components.d.ts @@ -146,6 +146,7 @@ declare module 'vue' { Snackbar: typeof import('./../../src/components/System/Snackbar.vue')['default'] SortButton: typeof import('./../../src/components/Buttons/SortButton.vue')['default'] SubtitleSelectionButton: typeof import('./../../src/components/Buttons/SubtitleSelectionButton.vue')['default'] + SubtitleTrack: typeof import('./../../src/components/Playback/SubtitleTrack.vue')['default'] SwiperSection: typeof import('./../../src/components/Layout/SwiperSection.vue')['default'] TaskManagerButton: typeof import('./../../src/components/Layout/AppBar/Buttons/TaskManagerButton.vue')['default'] TimeSlider: typeof import('./../../src/components/Layout/TimeSlider.vue')['default'] diff --git a/frontend/types/global/routes.d.ts b/frontend/types/global/routes.d.ts index 9da8e5489a3..c7e1e36dd46 100644 --- a/frontend/types/global/routes.d.ts +++ b/frontend/types/global/routes.d.ts @@ -37,6 +37,7 @@ declare module 'vue-router/auto-routes' { '/settings/apikeys': RouteRecordInfo<'/settings/apikeys', '/settings/apikeys', Record, Record>, '/settings/devices': RouteRecordInfo<'/settings/devices', '/settings/devices', Record, Record>, '/settings/logs-and-activity': RouteRecordInfo<'/settings/logs-and-activity', '/settings/logs-and-activity', Record, Record>, + '/settings/subtitles': RouteRecordInfo<'/settings/subtitles', '/settings/subtitles', Record, Record>, '/settings/users/': RouteRecordInfo<'/settings/users/', '/settings/users', Record, Record>, '/settings/users/[id]': RouteRecordInfo<'/settings/users/[id]', '/settings/users/:id', { id: ParamValue }, { id: ParamValue }>, '/settings/users/new': RouteRecordInfo<'/settings/users/new', '/settings/users/new', Record, Record>, From 9ddb61de64909dbf893e62853cbb9dd84166ac9a Mon Sep 17 00:00:00 2001 From: Sean McBroom Date: Thu, 6 Jun 2024 16:40:28 -0500 Subject: [PATCH 02/31] fix: change subtitle stroke css properties to work with chromium browsers --- frontend/src/store/client-settings.ts | 33 ++++++++------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/frontend/src/store/client-settings.ts b/frontend/src/store/client-settings.ts index 11910b110ee..e4aebc9c609 100644 --- a/frontend/src/store/client-settings.ts +++ b/frontend/src/store/client-settings.ts @@ -73,6 +73,13 @@ class ClientSettingsStore extends SyncedStore { * CSS Style Properties for subtitles */ public get subtitleStyle(): CSSProperties { + const strokeStyle = { + WebkitTextStrokeColor: "black", + WebkitTextStrokeWidth: "7px", + textShadow: "2px 2px 15px black", + paintOrder: "stroke fill" + } + return { fontFamily: `${this.subtitleAppearance.fontFamily}, ${FALLBACK_SUBTITLE_FONT} !important`, fontSize: `${this.subtitleAppearance.fontSize}em`, @@ -81,30 +88,10 @@ class ClientSettingsStore extends SyncedStore { padding: '10px', color: 'white', /** - * If stroke is enabled we use the textShadow property - * to create a stroke outline around the text + * Unwrap stroke style if stroke is enabled */ - textShadow: this.subtitleAppearance.stroke - ? ` - 4px 0 0 black, - 3.6956px 1.5308px 0 black, - 2.8284px 2.8284px 0 black, - 1.5308px 3.6956px 0 black, - 0 4px 0 black, - -1.5308px 3.6956px 0 black, - -2.8284px 2.8284px 0 black, - -3.6956px 1.5308px 0 black, - -4px 0 0 black, - -3.6956px -1.5308px 0 black, - -2.8284px -2.8284px 0 black, - -1.5308px -3.6956px 0 black, - 0 -4px 0 black, - 1.5308px -3.6956px 0 black, - 2.8284px -2.8284px 0 black, - 3.6956px -1.5308px 0 black, - 2px 2px 15px black !important` - : undefined - } + ...(this.subtitleAppearance.stroke && strokeStyle) + } } public readonly currentTheme = computed(() => { From 56f3212e441c31b18cccce7a5e9399fc8cf5e808 Mon Sep 17 00:00:00 2001 From: Sean McBroom Date: Sat, 8 Jun 2024 17:31:41 -0500 Subject: [PATCH 03/31] fix(settings): refactor subtitle page to await loading and general legibility fixes --- frontend/src/pages/settings/subtitles.vue | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/settings/subtitles.vue b/frontend/src/pages/settings/subtitles.vue index 2f5cab3bbfd..db93721f1e1 100644 --- a/frontend/src/pages/settings/subtitles.vue +++ b/frontend/src/pages/settings/subtitles.vue @@ -90,16 +90,13 @@ const loadAvailableFonts = async () => { }; const fontChecks = SUBTITLE_FONT_FAMILIES.map(checkFontAvailability); + const fonts = await Promise.all(fontChecks); + const validFonts = fonts.filter(font => font !== undefined); - Promise.all(fontChecks).then(fonts => { - const validFonts = fonts.filter(font => font !== undefined); - availableSubtitleFonts.value = validFonts; - }) + availableSubtitleFonts.value = validFonts; }; -onMounted(() => { - loadAvailableFonts() -}) +await loadAvailableFonts(); diff --git a/frontend/src/pages/settings/subtitles.vue b/frontend/src/pages/settings/subtitles.vue index db93721f1e1..7140bceca22 100644 --- a/frontend/src/pages/settings/subtitles.vue +++ b/frontend/src/pages/settings/subtitles.vue @@ -33,23 +33,14 @@ v-model="clientSettings.subtitleAppearance.stroke" :label="$t('stroke')"/> -
- - {{ $t('subtitlePreviewText') }} - -
+ - - diff --git a/frontend/src/store/client-settings.ts b/frontend/src/store/client-settings.ts index e4aebc9c609..6b23a1ed057 100644 --- a/frontend/src/store/client-settings.ts +++ b/frontend/src/store/client-settings.ts @@ -69,31 +69,6 @@ class ClientSettingsStore extends SyncedStore { return this._state.subtitleAppearance; } - /** - * CSS Style Properties for subtitles - */ - public get subtitleStyle(): CSSProperties { - const strokeStyle = { - WebkitTextStrokeColor: "black", - WebkitTextStrokeWidth: "7px", - textShadow: "2px 2px 15px black", - paintOrder: "stroke fill" - } - - return { - fontFamily: `${this.subtitleAppearance.fontFamily}, ${FALLBACK_SUBTITLE_FONT} !important`, - fontSize: `${this.subtitleAppearance.fontSize}em`, - marginBottom: `${this.subtitleAppearance.positionFromBottom}vh`, - backgroundColor: this.subtitleAppearance.backdrop ? 'rgba(0, 0, 0, 0.5)' : 'transparent', - padding: '10px', - color: 'white', - /** - * Unwrap stroke style if stroke is enabled - */ - ...(this.subtitleAppearance.stroke && strokeStyle) - } - } - public readonly currentTheme = computed(() => { const dark = 'dark'; const light = 'light'; diff --git a/frontend/src/utils/subtitles.ts b/frontend/src/utils/subtitles.ts index a655558adda..288e6c4213b 100644 --- a/frontend/src/utils/subtitles.ts +++ b/frontend/src/utils/subtitles.ts @@ -2,7 +2,7 @@ * Helper for subtitle manipulation and subtitle-related utility functions */ -import axios from "axios"; +import axios from 'axios'; interface Subtitle { start: number; @@ -10,8 +10,8 @@ interface Subtitle { text: string; } -export type ParsedSubtitleTrack = Subtitle[] -type TagMap = Record +export type ParsedSubtitleTrack = Subtitle[]; +type TagMap = Record; export const SUBTITLE_FONT_FAMILIES = [ 'Figtree Variable', @@ -33,7 +33,10 @@ export const FALLBACK_SUBTITLE_FONT = 'sans-serif, system-ui'; * Parse time string used in subtitle files to seconds */ function parseTime(timeString: string) { - const [hours, minutes, seconds] = timeString.split(':').map(Number.parseFloat); + const [hours, minutes, seconds] = timeString.split(':').map((element) => { + return Number.parseFloat(element); + }); + return hours * 3600 + minutes * 60 + seconds; } @@ -52,15 +55,17 @@ function formatText(text: string, tagMap: TagMap): string { /** * Parses a VTT (WebVTT) file from a given URL * Extracts dialogue lines with start and end times, and text content. - * + * * Converts specific tags to styled tags */ export async function parseVttFile(src: string) { try { - const file = await axios.get(src); + const file = await axios.get(src); const vttText: string = file.data; - if (!vttText) return + if (!vttText) { + return; + } const subtitles: ParsedSubtitleTrack = []; const vttLines = vttText.split('\n'); @@ -94,7 +99,7 @@ export async function parseVttFile(src: string) { '': '', '': '', '': '', - '': '
', + '
': '
' }); subtitles.push({ @@ -108,8 +113,8 @@ export async function parseVttFile(src: string) { } return subtitles; - } catch (err) { - console.error("Error parsing VTT subtitles", err); + } catch (error) { + console.error('Error parsing VTT subtitles', error); } } @@ -120,17 +125,13 @@ const parseSsaDialogue = (line: string, formatFields: string[]) => { const dialogueParts = line.split('Dialogue:')[1].split(',').map(field => field.trim()); const dialogueData: Record = {}; - formatFields.forEach((field, fieldIndex) => { - if (field === "Text") { - dialogueData[field] = dialogueParts.slice(fieldIndex).join(', ').trim(); - } else { - dialogueData[field] = dialogueParts[fieldIndex]?.trim(); - } - }); + for (const [fieldIndex, field] of formatFields.entries()) { + dialogueData[field] = field === 'Text' ? dialogueParts.slice(fieldIndex).join(', ').trim() : dialogueParts[fieldIndex]?.trim(); + } - const timeStart = dialogueData['Start']; - const timeEnd = dialogueData['End']; - const text = dialogueData['Text']; + const timeStart = dialogueData.Start; + const timeEnd = dialogueData.End; + const text = dialogueData.Text; const formattedText = formatText(text, { '{\\i1}': '', @@ -139,7 +140,7 @@ const parseSsaDialogue = (line: string, formatFields: string[]) => { '{\\b0}': '', '{\\u1}': '', '{\\u0}': '', - '{.*?}': '', // Remove other SSA tags + '{.*?}': '' // Remove other SSA tags }); return { start: parseTime(timeStart), end: parseTime(timeEnd), text: formattedText.trim() }; @@ -148,17 +149,19 @@ const parseSsaDialogue = (line: string, formatFields: string[]) => { /** * Parses an ASS/SSA (SubStation Alpha) file from a given URL. * Extracts dialogue lines with start and end times, and text content. - * + * * Converts specific tags to styled tags */ export async function parseSsaFile(src: string) { try { - const file = await axios.get(src); + const file = await axios.get(src); const ssaText: string = file.data; - if (!ssaText) return; + if (!ssaText) { + return; + } - // Dialouge lines + // Dialogue lines const ssaLines = ssaText.split('[Events]')[1].split('\n'); const subtitles: ParsedSubtitleTrack = []; @@ -180,7 +183,10 @@ export async function parseSsaFile(src: string) { * add consecutive lines at the same time together */ if (line.startsWith('Dialogue:')) { - if (!formatFields) break; // Format fields should be defined before dialogue begins + // Format fields should be defined before dialogue begins + if (formatFields.length === 0) { + break; + } const currentDialogue = parseSsaDialogue(line, formatFields); @@ -208,7 +214,7 @@ export async function parseSsaFile(src: string) { } return subtitles; - } catch (err) { - console.error('Error parsing ASS subtitles', err); + } catch (error) { + console.error('Error parsing SSA/ASS subtitles', error); } } From 6d2cda6f3c43f831d4841ca4f3cef236ed853904 Mon Sep 17 00:00:00 2001 From: Sean McBroom Date: Fri, 21 Jun 2024 00:42:49 -0500 Subject: [PATCH 08/31] refactor: update replaceTags method to replace entire tags --- frontend/src/utils/subtitles.ts | 45 ++++++++++++++------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/frontend/src/utils/subtitles.ts b/frontend/src/utils/subtitles.ts index 288e6c4213b..50dcfd12b4a 100644 --- a/frontend/src/utils/subtitles.ts +++ b/frontend/src/utils/subtitles.ts @@ -43,12 +43,17 @@ function parseTime(timeString: string) { /** * Formats the provided text by replacing specified tags with HTML elements. */ -function formatText(text: string, tagMap: TagMap): string { - let formattedText = text; - for (const [tag, replacement] of Object.entries(tagMap)) { - const regex = new RegExp(tag, 'g'); - formattedText = formattedText.replace(regex, replacement); +function replaceTags(input: string, tagMap: TagMap) { + let formattedText = input; + + // Iterate through tag mappings + for (const [htmlTag, markdownTag] of Object.entries(tagMap)) { + const regex = new RegExp(htmlTag, 'gi'); + formattedText = formattedText.replace(regex, (_, p1: string) => { + return markdownTag.replace('$1', p1); + }); } + return formattedText; } @@ -87,19 +92,12 @@ export async function parseVttFile(src: string) { i++; } - const formattedText = formatText(text, { - '': '', - '': '', - '': '', - '': '', - '': '', - '': '', - '': '', - '': '', - '': '', - '': '', - '': '', - '': '' + const formattedText = replaceTags(text, { + '(.*?)': '_$1_', // Italics + '(.*?)': '**$1**', // Bold + '(.*?)': '_$1_', // Italics + '(.*?)': '**$1**', // Bold + '
': '\n' // Line break }); subtitles.push({ @@ -133,14 +131,9 @@ const parseSsaDialogue = (line: string, formatFields: string[]) => { const timeEnd = dialogueData.End; const text = dialogueData.Text; - const formattedText = formatText(text, { - '{\\i1}': '', - '{\\i0}': '', - '{\\b1}': '', - '{\\b0}': '', - '{\\u1}': '', - '{\\u0}': '', - '{.*?}': '' // Remove other SSA tags + const formattedText = replaceTags(text, { + '{\\i1}(.*?){\\i0}': '_$1_', // Italics + '{\\b1}(.*?){\\b0}': '**$1**' // Bold }); return { start: parseTime(timeStart), end: parseTime(timeEnd), text: formattedText.trim() }; From c723dc16397b5a0e48131ece943301ccee6d656d Mon Sep 17 00:00:00 2001 From: Sean McBroom Date: Fri, 21 Jun 2024 14:12:29 -0500 Subject: [PATCH 09/31] fix: general tag formatting changes for rendering as html --- frontend/src/utils/subtitles.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/utils/subtitles.ts b/frontend/src/utils/subtitles.ts index 50dcfd12b4a..c3117c02838 100644 --- a/frontend/src/utils/subtitles.ts +++ b/frontend/src/utils/subtitles.ts @@ -48,9 +48,11 @@ function replaceTags(input: string, tagMap: TagMap) { // Iterate through tag mappings for (const [htmlTag, markdownTag] of Object.entries(tagMap)) { - const regex = new RegExp(htmlTag, 'gi'); + const escapedHtmlTag = htmlTag.replaceAll('\\', '\\\\'); + const regex = new RegExp(escapedHtmlTag, 'gi'); + formattedText = formattedText.replace(regex, (_, p1: string) => { - return markdownTag.replace('$1', p1); + return markdownTag.replace('$1', p1.trim()); }); } @@ -93,10 +95,6 @@ export async function parseVttFile(src: string) { } const formattedText = replaceTags(text, { - '(.*?)': '_$1_', // Italics - '(.*?)': '**$1**', // Bold - '(.*?)': '_$1_', // Italics - '(.*?)': '**$1**', // Bold '
': '\n' // Line break }); @@ -124,7 +122,9 @@ const parseSsaDialogue = (line: string, formatFields: string[]) => { const dialogueData: Record = {}; for (const [fieldIndex, field] of formatFields.entries()) { - dialogueData[field] = field === 'Text' ? dialogueParts.slice(fieldIndex).join(', ').trim() : dialogueParts[fieldIndex]?.trim(); + dialogueData[field] = field === 'Text' + ? dialogueParts.slice(fieldIndex).join(', ').trim() // Add dialogue together + : dialogueParts[fieldIndex]?.trim(); } const timeStart = dialogueData.Start; @@ -132,8 +132,8 @@ const parseSsaDialogue = (line: string, formatFields: string[]) => { const text = dialogueData.Text; const formattedText = replaceTags(text, { - '{\\i1}(.*?){\\i0}': '_$1_', // Italics - '{\\b1}(.*?){\\b0}': '**$1**' // Bold + '{\\i1}(.*?){\\i0}': '$1', // Italics + '{\\b1}(.*?){\\b0}': '$1' // Bold }); return { start: parseTime(timeStart), end: parseTime(timeEnd), text: formattedText.trim() }; From a048aebb90ec8efe300343aac007c006c97f2eb3 Mon Sep 17 00:00:00 2001 From: Sean McBroom Date: Mon, 24 Jun 2024 22:23:21 -0500 Subject: [PATCH 10/31] refactor: use uno css for subtitle track styles --- .../src/components/Playback/SubtitleTrack.vue | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/Playback/SubtitleTrack.vue b/frontend/src/components/Playback/SubtitleTrack.vue index 4ecdf7b847d..69f1bc9b959 100644 --- a/frontend/src/components/Playback/SubtitleTrack.vue +++ b/frontend/src/components/Playback/SubtitleTrack.vue @@ -1,15 +1,15 @@ + + diff --git a/frontend/src/components/Selectors/FontSelector.vue b/frontend/src/components/Selectors/FontSelector.vue index 8f507d808be..9bc199e61b4 100644 --- a/frontend/src/components/Selectors/FontSelector.vue +++ b/frontend/src/components/Selectors/FontSelector.vue @@ -6,7 +6,6 @@ icon="$warning"> {{ $t('queryLocalFontsNotSupportedWarning') }}
- {{ $t('enablePermission') }} + + {{ $t('askAgain') }} + diff --git a/frontend/src/components/lib/JApp.vue b/frontend/src/components/lib/JApp.vue index 8ac2d96e376..349887a1a31 100644 --- a/frontend/src/components/lib/JApp.vue +++ b/frontend/src/components/lib/JApp.vue @@ -11,6 +11,7 @@ cursor: wait; --j-color-background: rgb(var(--v-theme-background)); + --j-font-family: '{{ typography }}'; } @@ -22,7 +23,20 @@ /** * TODO: Investigate or propose an RFC to allow style tags inside SFCs */ +import { computed } from 'vue'; import { useLoading } from '@/composables/use-loading'; +import { DEFAULT_TYPOGRAPHY } from '@/store'; +import { clientSettings } from '@/store/client-settings'; const { isLoading } = useLoading(); + +const typography = computed(() => { + if (clientSettings.typography === 'system') { + return 'system-ui'; + } else if (clientSettings.typography === 'default') { + return DEFAULT_TYPOGRAPHY; + } else { + return clientSettings.typography; + } +}); diff --git a/frontend/src/composables/use-font.ts b/frontend/src/composables/use-font.ts deleted file mode 100644 index 2a483a9712d..00000000000 --- a/frontend/src/composables/use-font.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ref } from 'vue'; - -const currentFont = ref(''); - -/** - * Updates the currentFont reactive reference based on the font family of the document body. - * It retrieves the computed font style of the body and sets the currentFont to the primary font. - */ -const updateFont = () => { - const body = document.body; - const style = window.getComputedStyle(body); - - // Remove fallback fonts and quotes around the font name - const fontFamily = style.fontFamily.split(', ')[0].replaceAll(/["']/g, ''); - - currentFont.value = fontFamily; -}; - -// Event listener to update the font when the font loading is done -document.fonts.addEventListener('loadingdone', () => { - updateFont(); -}); - -// Initial font retrieval -updateFont(); - -/** - * Provides a reactive reference for the current font. - * - * @returns An object containing the `currentFont` reactive reference. - */ -export function useFont() { - return { currentFont }; -} diff --git a/frontend/src/pages/settings/subtitles.vue b/frontend/src/pages/settings/subtitles.vue index 405c40a4f55..adca8765cbd 100644 --- a/frontend/src/pages/settings/subtitles.vue +++ b/frontend/src/pages/settings/subtitles.vue @@ -1,48 +1,45 @@ diff --git a/frontend/src/store/client-settings/index.ts b/frontend/src/store/client-settings/index.ts index 18ed744f836..dd7a23d8d2d 100644 --- a/frontend/src/store/client-settings/index.ts +++ b/frontend/src/store/client-settings/index.ts @@ -8,6 +8,7 @@ import { remote } from '@/plugins/remote'; import { vuetify } from '@/plugins/vuetify'; import { sealed } from '@/utils/validation'; import { SyncedStore } from '@/store/super/synced-store'; +import type { TypographyChoices } from '@/store'; /** * == INTERFACES AND TYPES == @@ -15,6 +16,7 @@ import { SyncedStore } from '@/store/super/synced-store'; */ export interface ClientSettingsState { + typography: TypographyChoices; darkMode: 'auto' | boolean; locale: string; } @@ -44,6 +46,14 @@ class ClientSettingsStore extends SyncedStore { return this._state.locale; } + public get typography() { + return this._state.typography; + } + + public set typography(newVal: ClientSettingsState['typography']) { + this._state.typography = newVal; + } + public set darkMode(newVal: 'auto' | boolean) { this._state.darkMode = newVal; } @@ -72,6 +82,7 @@ class ClientSettingsStore extends SyncedStore { public constructor() { super('clientSettings', { + typography: 'default', darkMode: 'auto', locale: 'auto' }, 'localStorage'); diff --git a/frontend/src/store/client-settings/subtitle-settings.ts b/frontend/src/store/client-settings/subtitle-settings.ts index 9b9887d2d7e..5951ae4e5c7 100644 --- a/frontend/src/store/client-settings/subtitle-settings.ts +++ b/frontend/src/store/client-settings/subtitle-settings.ts @@ -2,13 +2,22 @@ import { watch } from 'vue'; import { remote } from '@/plugins/remote'; import { sealed } from '@/utils/validation'; import { SyncedStore } from '@/store/super/synced-store'; +import type { TypographyChoices } from '@/store'; /** * == INTERFACES AND TYPES == */ export interface SubtitleSettingsState { - fontFamily: string; + /** + * default: Default application typography. + * + * system: System typography + * + * auto: Selects the current selected typography for the application + * @default: auto + */ + fontFamily: 'auto' | TypographyChoices; fontSize: number; positionFromBottom: number; backdrop: boolean; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index a371fd46965..b31347236be 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -10,6 +10,16 @@ import { isNil } from '@/utils/validation'; * efficient to reuse those, both in components and TS files. */ +export const DEFAULT_TYPOGRAPHY = 'Figtree Variable'; +/** + * Type for the different typography choices across the application + * + * default: Default application typography. + * + * system: System typography + */ +export type TypographyChoices = 'default' | 'system' | (string & {}); + /** * == BLURHASH DEFAULTS == * By default, 20x20 pixels with a punch of 1 is returned. diff --git a/frontend/src/store/super/synced-store.ts b/frontend/src/store/super/synced-store.ts index 5602c95664c..5559588bd47 100644 --- a/frontend/src/store/super/synced-store.ts +++ b/frontend/src/store/super/synced-store.ts @@ -110,21 +110,19 @@ export abstract class SyncedStore extends CommonStore { try { const data = await this._fetchState(); - if (data) { - for (const watcher of this._pausableWatchers) { - watcher.pause(); - } + for (const watcher of this._pausableWatchers) { + watcher.pause(); + } - const newState = { - ...toRaw(this._state), - ...data - }; + const newState = { + ...toRaw(this._state), + ...data + }; - Object.assign(this._state, newState); + Object.assign(this._state, newState); - for (const watcher of this._pausableWatchers) { - watcher.resume(); - } + for (const watcher of this._pausableWatchers) { + watcher.resume(); } } catch { useSnackbar(i18n.t('failedSyncingUserSettings'), 'error'); @@ -144,12 +142,12 @@ export abstract class SyncedStore extends CommonStore { if (keys) { for (const key of keys) { this._pausableWatchers.push( - watchPausable(() => this._state[key], this._updateState) + watchPausable(() => this._state[key], this._updateState, { deep: true }) ); } } else { this._pausableWatchers.push( - watchPausable(this._state, this._updateState) + watchPausable(this._state, this._updateState, { deep: true }) ); } From c98ae0e07b5927d8e825921fd7866c1fb14f6ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Fern=C3=A1ndez?= Date: Sat, 7 Sep 2024 16:34:24 +0200 Subject: [PATCH 25/31] feat: don't apply subtitle settings if customization is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fernando Fernández --- frontend/locales/en.json | 1 + .../src/components/Playback/PlayerElement.vue | 6 +++-- .../src/components/Playback/SubtitleTrack.vue | 5 +---- .../src/components/Selectors/FontSelector.vue | 3 ++- frontend/src/pages/settings/subtitles.vue | 22 ++++++++++++++----- .../client-settings/subtitle-settings.ts | 6 +++++ 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index f24fb4288d6..3be81ad07c5 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -108,6 +108,7 @@ "editMetadata": "Edit metadata", "editPerson": "Edit person", "enablePermission": "Enable Permission", + "enableSubtitles": "Customize the subtitle appearance", "enableUPNP": "Enable UPnP", "endsAt": "Ends at {time}", "eps": "EPs", diff --git a/frontend/src/components/Playback/PlayerElement.vue b/frontend/src/components/Playback/PlayerElement.vue index e07a3eff947..54adba5224a 100644 --- a/frontend/src/components/Playback/PlayerElement.vue +++ b/frontend/src/components/Playback/PlayerElement.vue @@ -15,16 +15,17 @@ :loop="playbackManager.isRepeatingOnce" :class="{ 'uno-object-fill': playerElement.isStretched.value }" @loadeddata="onLoadedData"> + - @@ -43,6 +44,7 @@ import { playbackManager } from '@/store/playback-manager'; import { playerElement, videoContainerRef } from '@/store/player-element'; import { getImageInfo } from '@/utils/images'; import { isNil } from '@/utils/validation'; +import { subtitleSettings } from '@/store/client-settings/subtitle-settings'; const { t } = useI18n(); let busyWebAudio = false; diff --git a/frontend/src/components/Playback/SubtitleTrack.vue b/frontend/src/components/Playback/SubtitleTrack.vue index 64c44ae1298..5db245e0d56 100644 --- a/frontend/src/components/Playback/SubtitleTrack.vue +++ b/frontend/src/components/Playback/SubtitleTrack.vue @@ -1,9 +1,6 @@