diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 406a64c1857..3be81ad07c5 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -27,12 +27,14 @@ "anErrorHappened": "An error happened", "apiKeys": "API keys", "apiKeysSettingsDescription": "Add and revoke API keys for external access to your server", + "appDefaultTypography": "Application default typography ({value})", "appName": "App name", "appVersion": "App version", "appearsOn": "Appearances", "art": "Art", "artist": "Artist", "artists": "Artists", + "askAgain": "Ask again", "audio": "Audio", "auto": "Automatic", "backdrop": "Backdrop", @@ -72,6 +74,7 @@ "createKeySuccess": "Successfully created a new API key", "crew": "Crew", "criticRating": "Critic rating", + "currentAppTypography": "Current application typography ({value})", "currentPassword": "Current password", "customRating": "Custom rating", "dateAdded": "Date added", @@ -104,6 +107,8 @@ "dlnaSettingsDescription": "Configure DLNA settings and profile", "editMetadata": "Edit metadata", "editPerson": "Edit person", + "enablePermission": "Enable Permission", + "enableSubtitles": "Customize the subtitle appearance", "enableUPNP": "Enable UPnP", "endsAt": "Ends at {time}", "eps": "EPs", @@ -114,6 +119,7 @@ "filtersNotFound": "Unable to load filters", "finish": "Finish", "followSystemTheme": "Follow system theme", + "fontSize": "Font size", "fullScreen": "Full screen", "general": "General", "genericJellyfinPlaceholderDevice": "Generic Jellyfin device", @@ -144,7 +150,8 @@ "lastActive": "Last active", "lastActivityDate": "Last seen {value}", "latestLibrary": "Latest {libraryName}", - "lazyLoading": "Showing {value} items. Loading more…", + "lazyLoading": "Showing {value} items. Loading more...", + "learnMore": "Learn More", "libraries": "Libraries", "librariesSettingsDescription": "Manage libraries and their metadata", "libraryAccess": "Library access", @@ -154,6 +161,7 @@ "liked": "Liked", "liveTv": "Live TV & DVR", "liveTvSettingsDescription": "Manage TV tuners, guide data providers and DVR settings", + "localFontsPermissionWarning": "Access to the local fonts permission is required to select a font.", "login": "Login", "loginAs": "Login as {name}", "logo": "Logo", @@ -266,6 +274,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", @@ -277,6 +286,7 @@ "pushToBottom": "Move to the end", "pushToTop": "Move to the beginning", "quality": "Quality", + "queryLocalFontsNotSupportedWarning": "Local fonts are currently not supported by your browser.", "queue": "Queue", "queueItems": "{items} tracks", "rating": "Rating", @@ -353,13 +363,17 @@ "startNow": "Start now", "status": "Status", "stretch": "Stretch", + "stroke": "Stroke", "studios": "Studios", + "subtitleFont": "Subtitle font", + "subtitlePreviewText": "This is a preview of subtitles on this device.", "subtitles": "Subtitles", "subtitlesSettingsDescription": "Control how subtitles are displayed on this device", "switchToDarkMode": "Switch to dark mode", "switchToLightMode": "Switch to light mode", "syncPlayGroups": "SyncPlay groups", "syncingSettingsInProgress": "Syncing settings…", + "systemTypography": "System typography", "tagName": "Tag name", "tagline": "Tagline", "tags": "Tags", diff --git a/frontend/src/assets/styles/global.css b/frontend/src/assets/styles/global.css index 726c599213f..cf18c60336e 100644 --- a/frontend/src/assets/styles/global.css +++ b/frontend/src/assets/styles/global.css @@ -1,5 +1,5 @@ * { - font-family: 'Figtree Variable', sans-serif, system-ui !important; + font-family: var(--j-font-family), sans-serif, system-ui !important; } html { diff --git a/frontend/src/components/Playback/PlayerElement.vue b/frontend/src/components/Playback/PlayerElement.vue index e2f9ace2a7d..e7ca4eb0e9e 100644 --- a/frontend/src/components/Playback/PlayerElement.vue +++ b/frontend/src/components/Playback/PlayerElement.vue @@ -4,25 +4,29 @@ :to="videoContainerRef" :disabled="!videoContainerRef" defer> - - - +
+ + + + +
@@ -41,6 +45,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 new file mode 100644 index 00000000000..e15a951a014 --- /dev/null +++ b/frontend/src/components/Playback/SubtitleTrack.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/Selectors/FontSelector.vue b/frontend/src/components/Selectors/FontSelector.vue new file mode 100644 index 00000000000..e74196230b6 --- /dev/null +++ b/frontend/src/components/Selectors/FontSelector.vue @@ -0,0 +1,135 @@ + + + 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/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..4594ed59fb6 --- /dev/null +++ b/frontend/src/pages/settings/subtitles.vue @@ -0,0 +1,55 @@ + + + diff --git a/frontend/src/plugins/workers/generic.worker.ts b/frontend/src/plugins/workers/generic.worker.ts index c054b9ea613..bfae9a1fc6f 100644 --- a/frontend/src/plugins/workers/generic.worker.ts +++ b/frontend/src/plugins/workers/generic.worker.ts @@ -1,4 +1,5 @@ import { expose } from 'comlink'; +import { parseSsaFile, parseVttFile } from './generic/subtitles'; import { sealed } from '@/utils/validation'; /** @@ -20,6 +21,12 @@ class GenericWorker { return array; }; + + /** + * Functions for parsing subtitles + */ + public parseVttFile = parseVttFile; + public parseSsaFile = parseSsaFile; } const instance = new GenericWorker(); diff --git a/frontend/src/plugins/workers/generic/subtitles.ts b/frontend/src/plugins/workers/generic/subtitles.ts new file mode 100644 index 00000000000..2ea0463ea7b --- /dev/null +++ b/frontend/src/plugins/workers/generic/subtitles.ts @@ -0,0 +1,283 @@ +/** + * Helper for subtitle manipulation and subtitle-related utility functions + */ + +import axios from 'axios'; + +export interface Dialogue { + start: number; + end: number; + text: string; +} + +export interface ParsedSubtitleTrack { + dialogue: Dialogue[]; + isBasic?: boolean; +} + +type TagMap = Record; + +/** + * Parse time string used in subtitle files to seconds + */ +function parseTime(timeString: string) { + const [hours, minutes, seconds] = timeString.split(':').map((element) => { + return Number.parseFloat(element); + }); + + return hours * 3600 + minutes * 60 + seconds; +} + +/** + * Formats the provided text by replacing specified tags with HTML elements. + */ +function replaceTags(input: string, tagMap: TagMap) { + let formattedText = input; + + // Iterate through tag mappings + for (const [htmlTag, markdownTag] of Object.entries(tagMap)) { + const escapedHtmlTag = htmlTag.replaceAll('\\', '\\\\'); + const regex = new RegExp(escapedHtmlTag, 'gi'); + + formattedText = formattedText.replace(regex, (_, p1: string) => { + return markdownTag.replace('$1', p1.trim()); + }); + } + + return formattedText; +} + +/** + * 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 vttText: string = file.data; + + if (!vttText) { + return; + } + + const dialogue: Dialogue[] = []; + const vttLines = vttText.split('\n'); + + let i = 0; + + while (i < vttLines.length) { + 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++; + } + + const formattedText = replaceTags(text, { + '
': '\n' // Line break + }); + + dialogue.push({ + start: parseTime(start), + end: parseTime(end), + text: formattedText.trim() + }); + } else { + i++; + } + } + + const subtitles: ParsedSubtitleTrack = { + dialogue: dialogue, + isBasic: true + }; + + return subtitles; + } catch (error) { + console.error('Error parsing VTT subtitles', error); + } +} + +const parseFormatFields = (line: string) => line.split('Format:')[1].split(',').map(field => field.trim()); + +/** + * Extracts text from the SSA + */ +function parseFormattedLine(line: string, formatFields: string[]) { + const lineParts = line.slice(line.indexOf(':') + 1, -1).split(',').map(field => field.trim()); + const lineData: Record = {}; + + for (const [fieldIndex, field] of formatFields.entries()) { + lineData[field] = field === 'Text' + ? lineParts.slice(fieldIndex).join(', ').trim() // Add dialogue together + : lineParts[fieldIndex].trim(); + } + + return lineData; +}; + +/** + * Extracts styles from the SSA file + */ +function parseSsaStyles(lines: string[]) { + let formatFields: string[] = []; + const styles = []; + + for (const line of lines) { + if (line.startsWith('Format:')) { + formatFields = parseFormatFields(line); + } else if (line.startsWith('Style:')) { + const style = parseFormattedLine(line, formatFields); + + styles.push(style); + } + } + + return styles; +}; + +/** + * Parses dialogue line from SSA file. + */ +function parseSsaDialogue(line: string, formatFields: string[]): Dialogue { + const dialogueData = parseFormattedLine(line, formatFields); + + const timeStart = dialogueData.Start; + const timeEnd = dialogueData.End; + const text = dialogueData.Text; + + const formattedText = replaceTags(text, { + '{\\i1}(.*?){\\i0}': '$1', // Italics + '{\\b1}(.*?){\\b0}': '$1' // Bold + }); + + return { start: parseTime(timeStart), end: parseTime(timeEnd), text: formattedText.trim() }; +}; + +/** + * Parses dialogue lines from SSA file. + */ +function parseSsaDialogueLines(lines: string[]): Dialogue[] { + let index = 0; + let dialogueFormat: string[] = []; + const dialogue: Dialogue[] = []; + + const parseLine = (line: string, index: number): [Dialogue | undefined, number] => { + line = line.trim(); + + // Format fields should be defined before dialogue lines begin + if (line.startsWith('Dialogue:') && dialogueFormat.length !== 0) { + let currentDialogue = parseSsaDialogue(line, dialogueFormat); + + // Handle consecutive dialogue lines with the same timestamp + [currentDialogue, index] = parseConsecutiveLines(currentDialogue, index); + + return [currentDialogue, index]; + } else { + return [undefined, index]; + } + }; + + const parseConsecutiveLines = (currentDialogue: Dialogue, index: number): [Dialogue, number] => { + while (index + 1 < lines.length) { + const nextLine = lines[index + 1].trim(); + + if (nextLine.startsWith('Dialogue:')) { + const nextDialogue = parseSsaDialogue(nextLine, dialogueFormat); + + if (nextDialogue.start === currentDialogue.start && nextDialogue.end === currentDialogue.end) { + currentDialogue.text += '\n' + nextDialogue.text; + index++; + } else { + break; + } + } else { + break; + } + } + + currentDialogue.text = currentDialogue.text.replace(String.raw`\N`, '\n'); + + return [currentDialogue, index]; + }; + + while (index < lines.length) { + const line = lines[index]; + + /** + * Parse format fields and save to a variable + * to index data from dialogue lines + */ + if (line.startsWith('Format:')) { + dialogueFormat = parseFormatFields(line); + } + + /** + * Parse lines with Dialogue + * add consecutive lines at the same time together + */ + const [parsedDialogue, newIndex] = parseLine(line, index); + + if (parsedDialogue) { + dialogue.push(parsedDialogue); + } + + index = newIndex + 1; + } + + return dialogue; +}; + +/** + * 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): Promise { + try { + const file = await axios.get(src); + const ssaText: string = file.data; + + if (!ssaText) { + return; + } + + const sections = ssaText.split(/\r?\n\r?\n/); // Split into sections by empty lines + + let styles: Record[] | undefined = []; + let dialogue: Dialogue[] = []; + + for (const section of sections) { + if (section.startsWith('[V4 Styles]') || section.startsWith('[V4+ Styles]')) { + const lines = section.split('\n').slice(1); // Remove the [V4 Styles] line + + styles = parseSsaStyles(lines); + } else if (section.startsWith('[Events]')) { + const lines = section.split('\n').slice(1); // Remove the [Events] line + + dialogue = parseSsaDialogueLines(lines); + } + } + + const subtitles: ParsedSubtitleTrack = { + dialogue: dialogue, + /** + * Usually an advanced substation alpha file with many effects (karaoke, anime) + * will have more than one style defined, if there's only one + * we can assume it's basic + */ + isBasic: styles.length === 1 + }; + + return subtitles; + } catch (error) { + console.error('Error parsing SSA/ASS subtitles', error); + } +} diff --git a/frontend/src/store/client-settings.ts b/frontend/src/store/client-settings/index.ts similarity index 90% rename from frontend/src/store/client-settings.ts rename to frontend/src/store/client-settings/index.ts index 18ed744f836..dd7a23d8d2d 100644 --- a/frontend/src/store/client-settings.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 new file mode 100644 index 00000000000..5ccfe4b79fe --- /dev/null +++ b/frontend/src/store/client-settings/subtitle-settings.ts @@ -0,0 +1,66 @@ +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 { + /** + * Whether the customization of the subtitles is enabled or not + * @default: false + */ + enabled: boolean; + /** + * 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; + stroke: boolean; +} + +@sealed +class SubtitleSettingsStore extends SyncedStore { + public state = this._state; + + public constructor() { + super('subtitleSettings', { + enabled: false, + fontFamily: 'auto', + fontSize: 1.5, + positionFromBottom: 10, + backdrop: true, + stroke: false + }, 'localStorage', [ + 'enabled', + 'fontSize', + 'positionFromBottom', + 'backdrop', + 'stroke' + ]); + + /** + * == WATCHERS == + */ + watch( + () => remote.auth.currentUser, + () => { + if (!remote.auth.currentUser) { + this._reset(); + } + }, { flush: 'post' } + ); + } +} + +export const subtitleSettings = new SubtitleSettingsStore(); 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/playback-manager.ts b/frontend/src/store/playback-manager.ts index a5762d86f59..c49cd747ec1 100644 --- a/frontend/src/store/playback-manager.ts +++ b/frontend/src/store/playback-manager.ts @@ -297,40 +297,44 @@ class PlaybackManagerStore extends CommonStore { } /** - * Filters the native subtitles - * - * As our profile requires either SSA, PGS or VTT, if it's not SSA or PGS it'll be VTT. - * This is done this way as server sends as "Codec" the initial value of the track, so it can be webvtt, subrip, srt... - * This is easier to filter out the SSA subs + * Filters the external subtitle tracks */ - public get currentItemVttParsedSubtitleTracks(): PlaybackExternalTrack[] { + public get currentItemExternalParsedSubtitleTracks(): PlaybackExternalTrack[] { return ( this.currentItemParsedSubtitleTracks?.filter( (sub): sub is PlaybackExternalTrack => - !!sub.codec && sub.codec !== 'ass' && sub.codec !== 'ssa' && sub.codec !== 'pgssub' && !!sub.src + sub.codec !== undefined + && sub.src !== undefined ) ?? [] ); } + public get currentItemVttParsedSubtitleTracks(): PlaybackExternalTrack[] { + return ( + this.currentItemExternalParsedSubtitleTracks.filter( + sub => + sub.codec === 'vtt' + || sub.codec === 'srt' + || sub.codec === 'subrip' + ) + ); + } + public get currentItemAssParsedSubtitleTracks(): PlaybackExternalTrack[] { return ( - this.currentItemParsedSubtitleTracks?.filter( - (sub): sub is PlaybackExternalTrack => - !!sub.codec - && (sub.codec === 'ass' || sub.codec === 'ssa') - && !!sub.src - ) ?? [] + this.currentItemExternalParsedSubtitleTracks.filter( + sub => + sub.codec === 'ass' + || sub.codec === 'ssa' + ) ); } public get currentItemPgsParsedSubtitleTracks(): PlaybackExternalTrack[] { return ( - this.currentItemParsedSubtitleTracks?.filter( - (sub): sub is PlaybackExternalTrack => - !!sub.codec - && (sub.codec === 'pgssub') - && !!sub.src - ) ?? [] + this.currentItemExternalParsedSubtitleTracks.filter( + sub => sub.codec === 'pgssub' + ) ); } diff --git a/frontend/src/store/player-element.ts b/frontend/src/store/player-element.ts index fb7781d7f5b..aded6611888 100644 --- a/frontend/src/store/player-element.ts +++ b/frontend/src/store/player-element.ts @@ -10,18 +10,28 @@ 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 { useFullscreen } from '@vueuse/core'; +import { playbackManager, type PlaybackExternalTrack } from './playback-manager'; import { isArray, isNil, sealed } from '@/utils/validation'; -import { mediaElementRef } from '@/store'; +import { DEFAULT_TYPOGRAPHY, mediaElementRef } from '@/store'; import { CommonStore } from '@/store/super/common-store'; import { router } from '@/plugins/router'; import { remote } from '@/plugins/remote'; +import type { ParsedSubtitleTrack } from '@/plugins/workers/generic/subtitles'; +import { genericWorker } from '@/plugins/workers'; +import { subtitleSettings } from '@/store/client-settings/subtitle-settings'; + +interface SubtitleExternalTrack extends PlaybackExternalTrack { + parsed?: ParsedSubtitleTrack; +} /** * == INTERFACES AND TYPES == */ interface PlayerElementState { isStretched: boolean; + currentExternalSubtitleTrack?: SubtitleExternalTrack; } export const videoContainerRef = shallowRef(); @@ -47,6 +57,56 @@ 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(this.currentExternalSubtitleTrack) + && ( + this.currentExternalSubtitleTrack.codec === 'vtt' + || this.currentExternalSubtitleTrack.codec === 'srt' + || this.currentExternalSubtitleTrack.codec === 'subrip' + ); + } + + private get _usingExternalSsaSubtitles(): boolean { + return !isNil(this.currentExternalSubtitleTrack) + && ( + this.currentExternalSubtitleTrack.codec === 'ssa' + || this.currentExternalSubtitleTrack.codec === 'ass' + ); + } + + private get _usingExternalPgsSubtitles(): boolean { + return !isNil(this.currentExternalSubtitleTrack) + && ( + this.currentExternalSubtitleTrack.codec === 'pgssub' + ); + } + + /** + * Logic for applying custom subtitle track. + * + * Returns false if subtitle delivery method isn't external + * or if device is iOS/Android. + */ + private get _useCustomSubtitleTrack(): boolean { + return !isNil(playbackManager.currentSubtitleTrack) + && subtitleSettings.state.enabled + && playbackManager.currentSubtitleTrack.DeliveryMethod === SubtitleDeliveryMethod.External + /** + * If useFullscreen isn't supported we can assume the media player is Safari iOS + * in this case we wouldn't apply a custom subtitle track, since it cannot + * be rendered in Safari iOS's fullscreen element + */ + && useFullscreen().isSupported.value; + } + /** * == ACTIONS == */ @@ -64,13 +124,21 @@ class PlayerElementStore extends CommonStore { && mediaElementRef.value && mediaElementRef.value instanceof HTMLVideoElement ) { + const hasAttachedFonts = !isNil(attachedFonts) && attachedFonts.length !== 0; + this._jassub = new JASSUB({ video: mediaElementRef.value, subUrl: trackSrc, - fonts: attachedFonts, + ...(hasAttachedFonts + ? { + fonts: attachedFonts + } + : { + useLocalFonts: true + }), + fallbackFont: DEFAULT_TYPOGRAPHY, workerUrl: jassubWorker, wasmUrl: jassubWasmUrl, - fallbackFont: 'InterVariable', // Both parameters needed for subs to work on iOS prescaleFactor: 0.8, onDemandRender: false, @@ -111,7 +179,6 @@ class PlayerElementStore extends CommonStore { private readonly _setPgsTrack = (trackSrc: string): void => { if ( !this._pgssub - && mediaElementRef.value && mediaElementRef.value instanceof HTMLVideoElement ) { this._pgssub = new PgsRenderer({ @@ -132,51 +199,107 @@ class PlayerElementStore extends CommonStore { this._pgssub = undefined; }; + /** + * Applies PGS subtitles to the media element. + */ + private readonly _applyPgsSubtitles = (): void => { + if ( + mediaElementRef.value + && this.currentExternalSubtitleTrack + ) { + const subtitleTrack = this.currentExternalSubtitleTrack; + + this._setPgsTrack(subtitleTrack.src); + } + }; + + /** + * Applies VTT (WebVTT) subtitles to the media element. + */ + private readonly _applyVttSubtitles = async (): Promise => { + if ( + mediaElementRef.value + && this.currentExternalSubtitleTrack + ) { + const subtitleTrack = this.currentExternalSubtitleTrack; + + /** + * Check if client is able to display custom subtitle track + * otherwise show default subtitle track + */ + if (this._useCustomSubtitleTrack) { + const data = await genericWorker.parseVttFile(subtitleTrack.src); + + this.currentExternalSubtitleTrack.parsed = data; + } else { + mediaElementRef.value.textTracks[subtitleTrack.srcIndex].mode = 'showing'; + } + } + }; + + /** + * Applies SSA (SubStation Alpha) subtitles to the media element. + */ + private readonly _applySsaSubtitles = async (): Promise => { + if ( + mediaElementRef.value + && this.currentExternalSubtitleTrack + ) { + const subtitleTrack = this.currentExternalSubtitleTrack; + + /** + * Check if client is able to display custom subtitle track + * otherwise use JASSUB to render subtitles + */ + let applyJASSUB = !this._useCustomSubtitleTrack; + + if (this._useCustomSubtitleTrack) { + const data = await genericWorker.parseSsaFile(subtitleTrack.src); + + /** + * If style isn't basic (animations, custom typographics, etc.) + * fallback to rendering subtitles with JASSUB + */ + if (data?.isBasic) { + this.currentExternalSubtitleTrack.parsed = data; + } else { + applyJASSUB = true; + } + } + + if (applyJASSUB) { + const serverAddress = remote.sdk.api?.basePath; + + 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(subtitleTrack.src, attachedFonts); + } + } + }; + /** * 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 - * - * If embedded, a new transcode is automatically fetched from the playbackManager watchers. + * then filters the streams by codec and passes + * to the function to apply that codec */ public readonly applyCurrentSubtitle = async (): Promise => { - const serverAddress = remote.sdk.api?.basePath; - /** - * Finding (if it exists) the VTT or SSA track associated to the newly picked subtitle - */ - const vttIdx = playbackManager.currentItemVttParsedSubtitleTracks.findIndex( - sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex - ); - const ass = playbackManager.currentItemAssParsedSubtitleTracks.find( - sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex - ); - const pgs = playbackManager.currentItemPgsParsedSubtitleTracks.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 (!mediaElementRef.value) { return; } - await nextTick(); - /** - * 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 +309,34 @@ 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) { - /** - * If PGS, using libpgs to render - */ - this._setPgsTrack(pgs.src); + await nextTick(); + + // Search for selected external subtitle track + this.currentExternalSubtitleTrack = playbackManager.currentItemExternalParsedSubtitleTracks.find( + sub => sub.srcIndex === playbackManager.currentSubtitleStreamIndex + ); + + /** + * If selected external track exists, + * check which subtitle codec is being used and apply + */ + if (this.currentExternalSubtitleTrack) { + if (this._usingExternalPgsSubtitles) { + this._applyPgsSubtitles(); + } 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/store/super/common-store.ts b/frontend/src/store/super/common-store.ts index 8aa94ee85f3..933559042a5 100644 --- a/frontend/src/store/super/common-store.ts +++ b/frontend/src/store/super/common-store.ts @@ -1,5 +1,5 @@ import { useStorage, type RemovableRef } from '@vueuse/core'; -import { isRef, reactive } from 'vue'; +import { reactive, toValue } from 'vue'; import { mergeExcludingUnknown } from '@/utils/data-manipulation'; import { isNil } from '@/utils/validation'; @@ -11,7 +11,7 @@ export abstract class CommonStore { private readonly _internalState: T | RemovableRef; protected get _state(): T { - return isRef(this._internalState) ? this._internalState.value : this._internalState; + return toValue(this._internalState); } protected readonly _reset = (): void => { @@ -27,7 +27,7 @@ export abstract class CommonStore { if (persistence === 'localStorage') { storage = window.localStorage; } else if (persistence === 'sessionStorage') { - storage = sessionStorage; + storage = window.sessionStorage; } this._internalState = isNil(storage) 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 }) ); } diff --git a/frontend/types/global/components.d.ts b/frontend/types/global/components.d.ts index adc8896807e..51c09aaa1ae 100644 --- a/frontend/types/global/components.d.ts +++ b/frontend/types/global/components.d.ts @@ -27,6 +27,7 @@ declare module 'vue' { DateInput: typeof import('./../../src/components/Item/Metadata/DateInput.vue')['default'] DraggableQueue: typeof import('./../../src/components/Playback/DraggableQueue.vue')['default'] FilterButton: typeof import('./../../src/components/Buttons/FilterButton.vue')['default'] + FontSelector: typeof import('./../../src/components/Selectors/FontSelector.vue')['default'] GenericDialog: typeof import('./../../src/components/Dialogs/GenericDialog.vue')['default'] GenericItemCard: typeof import('./../../src/components/Item/Card/GenericItemCard.vue')['default'] IDashiconsAlbum: typeof import('~icons/dashicons/album')['default'] @@ -146,6 +147,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'] @@ -155,6 +157,7 @@ declare module 'vue' { UserButton: typeof import('./../../src/components/Layout/AppBar/Buttons/UserButton.vue')['default'] UserCard: typeof import('./../../src/components/Users/UserCard.vue')['default'] UserImage: typeof import('./../../src/components/Layout/Images/UserImage.vue')['default'] + VAlert: typeof import('vuetify/components')['VAlert'] VApp: typeof import('vuetify/components')['VApp'] VAppBar: typeof import('vuetify/components')['VAppBar'] VAppBarNavIcon: typeof import('vuetify/components')['VAppBarNavIcon'] 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>,