Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(subtitles): settings & custom element for external subtitles #2360

Merged
merged 31 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6f66f06
feat(subtitles): settings & custom element for external subtitles
seanmcbroom Jun 5, 2024
9ddb61d
fix: change subtitle stroke css properties to work with chromium brow…
seanmcbroom Jun 6, 2024
56f3212
fix(settings): refactor subtitle page to await loading and general le…
seanmcbroom Jun 8, 2024
3a6a4e4
refactor(subtitles): use computed value for current subtitle variable
seanmcbroom Jun 8, 2024
b8ad765
fix(locale): general fixes regarding subtitles
seanmcbroom Jun 8, 2024
2b3e52e
refactor(subtitles): make SSA file parsing more reliable and add mult…
seanmcbroom Jun 9, 2024
b48f2d8
refactor: reuse SubtitleTrack element in subtitle settings page
seanmcbroom Jun 21, 2024
6d2cda6
refactor: update replaceTags method to replace entire tags
seanmcbroom Jun 21, 2024
c723dc1
fix: general tag formatting changes for rendering as html
seanmcbroom Jun 21, 2024
a048aeb
refactor: use uno css for subtitle track styles
seanmcbroom Jun 25, 2024
6b1cbc0
refactor: check if useFullscreen is supported to apply custom subtitl…
seanmcbroom Jun 25, 2024
2c44ebc
refactor: add logic for basic sass subtitles
seanmcbroom Jul 1, 2024
9ded2d8
refactor: link fontsize & position sliders directly to client settings
seanmcbroom Jul 9, 2024
114617f
feat(FontSelector): refactor font selector logic and move to component
seanmcbroom Jul 18, 2024
c875ff2
feat(font-settings): query font from document css
seanmcbroom Jul 26, 2024
eba345c
style: automatic eslint fix
ferferga Aug 14, 2024
bea50ad
fix: handle cases where ssa sub dialogue include newline character
seanmcbroom Aug 14, 2024
e7193fe
refactor(player-element): cleanup & standardize applying subtitles
seanmcbroom Aug 14, 2024
99b4827
refactor(playback-manager): reduce shared code
seanmcbroom Aug 15, 2024
fd0398d
refactor: fix sonarcloud issues
seanmcbroom Aug 16, 2024
22f025c
feat: implement useFont compostable
seanmcbroom Aug 23, 2024
20e5480
refactor: extract all subtitle setting functionality to new store
seanmcbroom Aug 24, 2024
49c313c
refactor: simplify subtitle store
ferferga Sep 7, 2024
89a5dbc
refactor&fix: font
ferferga Sep 7, 2024
c98ae0e
feat: don't apply subtitle settings if customization is disabled
ferferga Sep 7, 2024
fb4c56a
refactor: extract all logic to webworker
ferferga Sep 8, 2024
bdacaff
perf: optimize subtitle finding
ferferga Sep 8, 2024
1e8d0bc
refactor(store): skip syncing font family
ferferga Sep 10, 2024
ec3d5e2
refactor: address review comments
ferferga Sep 10, 2024
74ab7d3
refactor: fix displaying subtitle tracks
seanmcbroom Sep 11, 2024
aa797ae
refactor: improvements and fixes for jassub rendering
seanmcbroom Sep 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/assets/styles/global.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
43 changes: 24 additions & 19 deletions frontend/src/components/Playback/PlayerElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,29 @@
:to="videoContainerRef"
:disabled="!videoContainerRef"
defer>
<Component
:is="mediaElementType"
v-show="mediaElementType === 'video' && videoContainerRef"
ref="mediaElementRef"
:poster="String(posterUrl)"
autoplay
crossorigin
playsinline
:loop="playbackManager.isRepeatingOnce"
:class="{ 'uno-object-fill': playerElement.isStretched.value }"
@loadeddata="onLoadedData">
<track
v-for="sub in playbackManager.currentItemVttParsedSubtitleTracks"
:key="`${playbackManager.currentSourceUrl}-${sub.srcIndex}`"
kind="subtitles"
:label="sub.label"
:srclang="sub.srcLang"
:src="sub.src">
</Component>
<div class="uno-my-auto">
<Component
:is="mediaElementType"
v-show="mediaElementType === 'video' && videoContainerRef"
ref="mediaElementRef"
:poster="String(posterUrl)"
autoplay
crossorigin
playsinline
:loop="playbackManager.isRepeatingOnce"
:class="{ 'uno-object-fill': playerElement.isStretched.value, 'uno-max-h-100vh': true}"
@loadeddata="onLoadedData">
<track
v-for="sub in playbackManager.currentItemVttParsedSubtitleTracks"
:key="`${playbackManager.currentSourceUrl}-${sub.srcIndex}`"
kind="subtitles"
:label="sub.label"
:srclang="sub.srcLang"
:src="sub.src">
</Component>
<SubtitleTrack
v-if="subtitleSettings.state.enabled && playerElement.currentExternalSubtitleTrack?.parsed !== undefined" />
Comment on lines +27 to +28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously we were showing the browser's tracks alongisde our custom ones. I guess this is not the wanted behaviour, right? I also added a new toggle in the settings to toggle the subtitle customization (that's what configures the subtitleSettings.state.enabled variable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree the only time it should show the custom track is when the subtitle customization is enabled/available, otherwise it should show the default track element.

</div>
</Teleport>
</template>
</template>
Expand All @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/components/Playback/SubtitleTrack.vue
seanmcbroom marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<template>
<div
class="uno-absolute uno-bottom-0 uno-left-0 uno-w-full uno-text-center">
<span
class="uno-inline-block uno-pb-10px uno-color-white"
:class="{ 'stroked': subtitleSettings.state.stroke }"
:style="subtitleStyle">
<template v-if="preview">
{{ $t('subtitlePreviewText') }}
</template>
<JSafeHtml
v-else-if="!isNil(currentSubtitle?.sub)"
:html="currentSubtitle.sub.text" />
</span>
</div>
</template>

<script setup lang="ts">
import { computed, type StyleValue } from 'vue';
import { subtitleSettings } from '@/store/client-settings/subtitle-settings';
import { DEFAULT_TYPOGRAPHY } from '@/store';
import { playerElement } from '@/store/player-element';
import { isNil } from '@/utils/validation';
import type { ParsedSubtitleTrack, Dialogue } from '@/plugins/workers/generic/subtitles';
import { playbackManager } from '@/store/playback-manager';

const { preview } = defineProps<{
/**
* Whether the subtitle track is in preview mode.
*/
preview?: boolean;
}>();

/**
* Update the current subtitle based on the current time of the media element.
*
* Loops in the first run (we can't assume that the first run will appear at index 0,
* since the user can seek to any position) when 'previous' is undefined and then relies in previous
* to find the next one
*/
const predicate = (d: Dialogue) => d.start <= playbackManager.currentTime && d.end >= playbackManager.currentTime;
const findSubtitle = (dialogue: ParsedSubtitleTrack['dialogue'], start = 0) => {
const index = dialogue.slice(start).findIndex(d => predicate(d));

return index === -1 ? undefined : index;
};

const dialogue = computed(() => playerElement.currentExternalSubtitleTrack?.parsed?.dialogue);
const currentSubtitle = computed<{ index: number; sub?: Dialogue | undefined } | undefined>((previous) => {
if (!isNil(dialogue.value)) {
const hasPrevious = !isNil(previous);
const nextIndex = hasPrevious ? previous.index + 1 : 0;
const isNext = hasPrevious && predicate(dialogue.value[nextIndex]);
const isCurrent = hasPrevious && predicate(dialogue.value[previous.index]);

if (isCurrent) {
return previous;
} else {
const newIndex = isNext ? nextIndex : findSubtitle(dialogue.value, nextIndex);

if (!isNil(newIndex)) {
return { index: newIndex, sub: dialogue.value[newIndex] };
} else if (hasPrevious) {
return { index: previous.index };
}
}
}
});

const fontFamily = computed(() => {
if (subtitleSettings.state.fontFamily === 'default') {
return DEFAULT_TYPOGRAPHY;
} else if (subtitleSettings.state.fontFamily === 'system') {
return 'system-ui';
} else if (subtitleSettings.state.fontFamily !== 'auto') {
return subtitleSettings.state.fontFamily;
}
});

/**
* Computed style for subtitle text element
* reactive to client subtitle appearance settings
*/
const subtitleStyle = computed<StyleValue>(() => {
const subtitleAppearance = subtitleSettings.state;

return {
fontSize: `${subtitleAppearance.fontSize}em`,
marginBottom: `${subtitleAppearance.positionFromBottom}vh`,
backgroundColor: subtitleAppearance.backdrop ? 'rgba(0, 0, 0, 0.5)' : 'transparent',
/**
* Unwrap font family and stroke style if stroke is enabled
*/
...(fontFamily.value && {
fontFamily: `${fontFamily.value} !important`
})
};
});
</script>

<style scoped>
.stroked {
-webkit-text-stroke: 7px black;
text-shadow: 2px 2px 15px black;
paint-order: stroke fill;
}
</style>
135 changes: 135 additions & 0 deletions frontend/src/components/Selectors/FontSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<VAlert
v-if="!isQueryLocalFontsSupported"
class="uno-mb-5"
color="warning"
icon="$warning">
{{ $t('queryLocalFontsNotSupportedWarning') }}
<br>
<a
class="uno-font-bold"
href="https://caniuse.com/mdn-api_window_querylocalfonts"
target="_blank"
rel="noopener">
{{ $t('learnMore') }}
</a>
</VAlert>

<VAlert
v-else-if="!fontAccess"
class="uno-mb-5"
color="warning"
icon="$warning">
{{ $t('localFontsPermissionWarning') }}
<br>
<a
class="uno-font-bold"
href="https://support.google.com/chrome/answer/114662?hl=en&co=GENIE.Platform=Desktop"
target="_blank"
rel="noopener">
{{ $t('enablePermission') }}
</a>
<a
class="uno-font-bold"
@click="askForPermission">
{{ $t('askAgain') }}
</a>
</VAlert>

<VSelect
v-model="_model"
v-bind="$attrs"
:items="selection"
:disabled="!isQueryLocalFontsSupported || !fontAccess || disabled" />
</template>

<script setup lang="ts">
import { computedAsync, usePermission, useSupported } from '@vueuse/core';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { clientSettings } from '@/store/client-settings';
import { DEFAULT_TYPOGRAPHY } from '@/store';

const { appWide } = defineProps<{
/**
* If this font selector is used for selecting the typography for the whole app
*/
appWide?: boolean;
disabled?: boolean;
}>();

const model = defineModel<string | undefined>();
const { t } = useI18n();

const { query: permissionQuery, isSupported, state: fontPermission } = usePermission('local-fonts', { controls: true });
const fontAccess = computed(() => fontPermission.value === 'granted');
const isQueryLocalFontsSupported = useSupported(() => isSupported.value && 'queryLocalFonts' in window);
const askForPermission = async () => isQueryLocalFontsSupported.value
? Promise.all([permissionQuery, window.queryLocalFonts])
: undefined;

/**
* Edge at least doesn't allow for querying the permission directly using navigator.permission,
* only after querying the fonts, so we perform the query regardless at the beginning.
*/
const fontList = computedAsync(async () => {
const res: string[] = [];

if (fontAccess.value || isQueryLocalFontsSupported.value) {
const set = new Set<string>((await window.queryLocalFonts()).map((font: FontFace) => font.family));

/**
* Removes the current selected tpography (in case it's not the default one)
*/
set.delete(clientSettings.typography);
res.push(...set);
}

return res;
}, []);

const selection = computed(() => {
const res = [
{
title: t('appDefaultTypography', { value: DEFAULT_TYPOGRAPHY }),
value: 'default'
},
{
title: t('systemTypography'),
value: 'system'
}, ...fontList.value.map(f => ({
title: f,
value: f
}))];

if (!appWide && !['system', 'default'].includes(clientSettings.typography)) {
res.unshift(
{
title: t('currentAppTypography', {
value: clientSettings.typography
}),
value: clientSettings.typography
}
);
}

return res;
});

const _model = computed({
get() {
if (appWide) {
return clientSettings.typography;
}

return model.value;
},
set(newVal) {
if (appWide && newVal) {
clientSettings.typography = newVal;
}

model.value = newVal;
}
});
</script>
Loading
Loading