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 @@
+
+
+
+
+ {{ $t('subtitlePreviewText') }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+ {{ $t('queryLocalFontsNotSupportedWarning') }}
+
+
+ {{ $t('learnMore') }}
+
+
+
+
+ {{ $t('localFontsPermissionWarning') }}
+
+
+ {{ $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/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 @@
+
+
+
+ {{ $t('subtitles') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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>,