feat(video-osd): implement proper OSD design

Based on @Perseusdehond's design
This commit is contained in:
MrTimscampi 2021-04-05 16:27:40 +02:00
parent 5197af3f4b
commit d7d11af2fc
38 changed files with 719 additions and 261 deletions

View File

@ -103,7 +103,7 @@ body {
.pa-s {
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.pt-s {

View File

@ -0,0 +1,108 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
:close-on-click="false"
:transition="'slide-y-transition'"
bottom
:nudge-bottom="nudgeBottom"
offset-y
min-width="25em"
max-width="25em"
min-height="25em"
max-height="25em"
:z-index="500"
class="menu"
>
<template #activator="{ on: menu, attrs }">
<v-tooltip bottom>
<template #activator="{ on: tooltip }">
<v-btn
class="align-self-center active-button"
:icon="!fab"
:fab="fab"
small
disabled
:class="{ 'ml-1': fab }"
v-bind="attrs"
v-on="{ ...tooltip, ...menu }"
>
<v-icon>mdi-cast</v-icon>
</v-btn>
</template>
<span>{{ $t('remoteDevices') }}</span>
</v-tooltip>
</template>
<v-card>
<v-list color="transparent">
<v-list-item-group>
<client-only>
<v-list-item>
<v-list-item-icon>
<v-icon>mdi-account-group</v-icon>
</v-list-item-icon>
<v-list-item-content>
{{ $t('syncPlayGroups') }}
</v-list-item-content>
<v-list-item-action>
<v-icon>mdi-arrow-right</v-icon>
</v-list-item-action>
</v-list-item>
<v-divider />
<v-list-item v-if="$features.airPlay">
<v-list-item-icon>
<v-icon>mdi-apple-airplay</v-icon>
</v-list-item-icon>
<v-list-item-content>
{{ $t('airPlayDevices') }}
</v-list-item-content>
</v-list-item>
<v-list-item v-if="$features.googleCast">
<v-list-item-icon>
<v-icon>mdi-cast</v-icon>
</v-list-item-icon>
<v-list-item-content>
{{ $t('googleCastPlaceholderDevice') }}
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-icon>
<v-icon>$vuetify.icons.jellyfin</v-icon>
</v-list-item-icon>
<v-list-item-content>
{{ $t('genericJellyfinPlaceholderDevice') }}
</v-list-item-content>
</v-list-item>
</client-only>
</v-list-item-group>
</v-list>
</v-card>
</v-menu>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters, mapState } from 'vuex';
export default Vue.extend({
props: {
fab: {
type: Boolean,
required: false
},
nudgeBottom: {
type: Number,
default: 5
}
},
data() {
return {
menu: false
};
},
computed: {
...mapGetters('playbackManager', ['getCurrentItemSubtitleTracks']),
...mapState('playbackManager', ['currentSubtitleStreamIndex'])
}
});
</script>

View File

@ -0,0 +1,123 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
:close-on-click="false"
:transition="'slide-y-transition'"
top
:nudge-top="nudgeTop"
offset-y
min-width="25em"
max-width="25em"
:z-index="500"
class="menu"
>
<template #activator="{ on: menu, attrs }">
<v-tooltip top>
<template #activator="{ on: tooltip }">
<v-btn
class="align-self-center active-button"
icon
disabled
v-bind="attrs"
v-on="{ ...tooltip, ...menu }"
>
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
<span>{{ $t('playbackSettings') }}</span>
</v-tooltip>
</template>
<v-card>
<v-list color="transparent">
<v-list-item>
<v-row align="center">
<v-col :cols="4">
<label>{{ $t('quality') }}</label>
</v-col>
<v-col :cols="8">
<track-selector
:item="getCurrentItem"
:media-source-index="0"
type="Subtitles"
@input="currentAudioTrack = $event"
/>
</v-col>
</v-row>
</v-list-item>
<v-list-item>
<v-row align="center">
<v-col :cols="4">
<label>{{ $t('audio') }}</label>
</v-col>
<v-col :cols="8">
<track-selector
:item="getCurrentItem"
:media-source-index="0"
type="Audio"
@input="currentAudioTrack = $event"
/>
</v-col>
</v-row>
</v-list-item>
<v-list-item>
<v-row align="center">
<v-col :cols="4">
<label>{{ $t('subtitles') }}</label>
</v-col>
<v-col :cols="8">
<track-selector
:item="getCurrentItem"
:media-source-index="0"
type="Subtitles"
@input="currentAudioTrack = $event"
/>
</v-col>
</v-row>
</v-list-item>
<v-list-item>
<v-row align="center">
<v-col :cols="4">
<label>{{ $t('speed') }}</label>
</v-col>
<v-col :cols="8">
<track-selector
:item="getCurrentItem"
:media-source-index="0"
type="Subtitles"
@input="currentAudioTrack = $event"
/>
</v-col>
</v-row>
</v-list-item>
<v-list-item>
<v-list-item-content>
{{ $t('playbackData') }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
export default Vue.extend({
props: {
nudgeTop: {
type: Number,
default: 0
}
},
data() {
return {
menu: false
};
},
computed: {
...mapGetters('playbackManager', ['getCurrentItem'])
}
});
</script>

View File

@ -5,20 +5,25 @@
:close-on-click="false"
:transition="'slide-y-transition'"
top
:nudge-top="35"
:nudge-top="nudgeTop"
offset-y
min-width="35vw"
max-width="35vw"
min-height="60vh"
max-height="60vh"
:z-index="100"
:z-index="500"
class="menu"
>
<!-- eslint-disable-next-line vue/no-template-shadow -->
<template #activator="{ on: menu, attrs }">
<v-tooltip top>
<template #activator="{ on: tooltip }">
<v-btn icon v-bind="attrs" v-on="{ ...tooltip, ...menu }">
<v-btn
class="align-self-center active-button"
icon
v-bind="attrs"
v-on="{ ...tooltip, ...menu }"
>
<v-icon>mdi-playlist-play</v-icon>
</v-btn>
</template>
@ -42,10 +47,10 @@
</v-list-item-content>
<v-list-item-action>
<like-button v-if="initiator" :item="item" />
<like-button v-if="initiator" :item="getCurrentItem" />
</v-list-item-action>
<v-list-item-action class="mr-1">
<item-menu v-if="initiator" :item="item" />
<item-menu v-if="initiator" :item="getCurrentItem" />
</v-list-item-action>
</v-list-item>
</v-list>
@ -82,16 +87,16 @@
<script lang="ts">
import { BaseItemDto } from '@jellyfin/client-axios';
import Vue from 'vue';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import { InitMode } from '~/store/playbackManager';
import timeUtils from '~/mixins/timeUtils';
export default Vue.extend({
mixins: [timeUtils],
props: {
item: {
type: Object as () => BaseItemDto,
required: true
nudgeTop: {
type: Number,
default: 0
}
},
data() {
@ -101,6 +106,7 @@ export default Vue.extend({
};
},
computed: {
...mapGetters('playbackManager', ['getCurrentItem']),
...mapState('playbackManager', [
'queue',
'playbackInitiator',
@ -116,7 +122,7 @@ export default Vue.extend({
case InitMode.Unknown:
return this.$t('playback.playbackSource.unknown');
case InitMode.Item:
if (this.item.AlbumId !== this.playbackInitiator?.Id) {
if (this.getCurrentItem.AlbumId !== this.playbackInitiator?.Id) {
return this.$t('playback.playbackSource.unknown');
} else {
return this.$t('playback.playbackSource.item', {
@ -126,7 +132,7 @@ export default Vue.extend({
case InitMode.Shuffle:
return this.$t('playback.playbackSource.shuffle');
case InitMode.ShuffleItem:
if (this.item.AlbumId !== this.playbackInitiator?.Id) {
if (this.getCurrentItem.AlbumId !== this.playbackInitiator?.Id) {
return this.$t('playback.playbackSource.unknown');
} else {
return this.$t('playback.playbackSource.shuffleItem', {
@ -140,7 +146,7 @@ export default Vue.extend({
},
initiator: {
get(): BaseItemDto | null {
if (this.item.AlbumId === this.playbackInitiator?.Id) {
if (this.getCurrentItem.AlbumId === this.playbackInitiator?.Id) {
return this.playbackInitiator;
}

View File

@ -0,0 +1,74 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
:close-on-click="false"
:transition="'slide-y-transition'"
top
:nudge-top="nudgeTop"
offset-y
min-width="25em"
max-width="25em"
min-height="25em"
max-height="25em"
:z-index="500"
class="menu"
>
<template #activator="{ on: menu, attrs }">
<v-tooltip top>
<template #activator="{ on: tooltip }">
<v-btn
class="align-self-center active-button"
icon
disabled
v-bind="attrs"
v-on="{ ...tooltip, ...menu }"
>
<v-icon>mdi-closed-caption</v-icon>
</v-btn>
</template>
<span>{{ $t('subtitles') }}</span>
</v-tooltip>
</template>
<v-card>
<v-list color="transparent">
<v-list-item
v-for="(track, index) of getCurrentItemSubtitleTracks"
:key="track.Index"
>
<v-list-item-icon>
<v-icon v-if="index === currentSubtitleStreamIndex">
mdi-check
</v-icon>
</v-list-item-icon>
<v-list-item-content>
{{ track.DisplayTitle }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters, mapState } from 'vuex';
export default Vue.extend({
props: {
nudgeTop: {
type: Number,
default: 0
}
},
data() {
return {
menu: false
};
},
computed: {
...mapGetters('playbackManager', ['getCurrentItemSubtitleTracks']),
...mapState('playbackManager', ['currentSubtitleStreamIndex'])
}
});
</script>

View File

@ -171,7 +171,7 @@ export default Vue.extend({
cardSubtitle(): string {
switch (this.item.Type) {
case 'Episode':
return `${this.$t('tvShowAbbrev', {
return `${this.$t('seasonEpisodeAbbrev', {
seasonNumber: this.item.ParentIndexNumber,
episodeNumber: this.item.IndexNumber
})} - ${this.item.Name}`;

View File

@ -112,7 +112,7 @@
</v-col>
<v-col cols="3" class="d-none d-md-flex align-center justify-end">
<like-button :item="getCurrentItem" class="active-button" />
<queue-button :item="getCurrentItem" class="active-button" />
<queue-button nudge-top="35" />
<div class="hidden-lg-and-down">
<volume-slider />
</div>

View File

@ -34,36 +34,6 @@
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div
class="absolute d-flex flex-row justify-center align-center"
>
<v-btn
class="all-pointer-events"
icon
large
@click="setPreviousTrack"
>
<v-icon size="32">mdi-skip-previous</v-icon>
</v-btn>
<v-btn
class="all-pointer-events"
icon
x-large
@click="playPause"
>
<v-icon size="48">
{{ isPaused ? 'mdi-play' : 'mdi-pause' }}
</v-icon>
</v-btn>
<v-btn
class="all-pointer-events"
icon
large
@click="setNextTrack"
>
<v-icon size="32">mdi-skip-next</v-icon>
</v-btn>
</div>
</div>
</v-overlay>
</v-fade-transition>
@ -71,13 +41,14 @@
<v-fade-transition>
<v-overlay
v-show="!isMinimized && showFullScreenOverlay"
color="transparent"
absolute
>
<div
class="d-flex flex-column justify-space-between align-center player-overlay"
>
<div class="osd-top pt-s pl-s pr-s">
<div class="d-flex justify-space-between align-center">
<div class="d-flex align-center py-2 px-4">
<div class="d-flex">
<v-btn icon @click="stopPlayback">
<v-icon>mdi-close</v-icon>
@ -85,63 +56,87 @@
<v-btn icon @click="toggleMinimized">
<v-icon>mdi-chevron-down</v-icon>
</v-btn>
<v-btn
v-if="$features.pictureInPicture"
icon
@click="togglePictureInPicture"
>
<v-icon>mdi-picture-in-picture-bottom-right</v-icon>
</v-btn>
</div>
<p class="ma-0 text-center">{{ currentItemName }}</p>
<div class="d-flex">
<v-btn icon disabled>
<v-icon>mdi-autorenew</v-icon>
</v-btn>
<v-btn v-if="$features.airplay" icon disabled>
<v-icon>mdi-apple-airplay</v-icon>
</v-btn>
<v-btn icon disabled>
<v-icon>mdi-cast</v-icon>
</v-btn>
<div class="d-flex ml-auto">
<cast-button />
</div>
</div>
</div>
<div class="osd-bottom pb-s pl-s pr-s">
<div class="px-4">
<div class="pa-4">
<time-slider />
<div class="d-flex justify-space-between">
<div>
<v-btn icon @click="setPreviousTrack">
<v-icon>mdi-skip-previous</v-icon>
</v-btn>
<v-btn icon @click="playPause">
<v-icon>
{{ isPaused ? 'mdi-play' : 'mdi-pause' }}
</v-icon>
</v-btn>
<v-btn icon @click="setNextTrack">
<v-icon icon>mdi-skip-next</v-icon>
</v-btn>
</div>
<div>
<v-btn icon disabled>
<v-icon>mdi-closed-caption</v-icon>
</v-btn>
<v-btn icon disabled>
<v-icon>mdi-cog</v-icon>
</v-btn>
<v-btn icon @click="toggleFullScreen">
<v-icon>
<div
class="controls-wrapper d-flex align-stretch justify-space-between"
>
<div
class="d-flex flex-column align-start justify-center mr-auto video-title"
>
<template v-if="getCurrentItem.Type === 'Episode'">
<span class="mt-1 text-subtitle-1">
{{ getCurrentItem.Name }}
</span>
<span class="text-subtitle-2 text--secondary">
{{ getCurrentItem.SeriesName }}
</span>
<span class="text-subtitle-2 text--secondary">
{{
fullScreenVideo
? 'mdi-fullscreen-exit'
: 'mdi-fullscreen'
$t('seasonEpisode', {
seasonNumber:
getCurrentItem.ParentIndexNumber,
episodeNumber: getCurrentItem.IndexNumber
})
}}
</span>
</template>
<template v-else>
<span>{{ getCurrentItem.Name }}</span>
</template>
</div>
<div
class="d-flex player-controls align-center justify-center"
>
<v-btn icon class="mx-1" @click="setPreviousTrack">
<v-icon> mdi-skip-previous </v-icon>
</v-btn>
<v-btn
icon
class="mx-1 active-button"
@click="playPause"
>
<v-icon large>
{{
isPaused
? 'mdi-play-circle-outline'
: 'mdi-pause-circle-outline'
}}
</v-icon>
</v-btn>
<v-btn icon class="mx-1" @click="setNextTrack">
<v-icon icon> mdi-skip-next</v-icon>
</v-btn>
</div>
<div class="d-flex aligh-center">
<volume-slider class="mr-2" />
<queue-button nudge-top="60" />
<subtitle-selection-button nudge-top="60" />
<playback-settings-button nudge-top="60" />
<v-btn
v-if="$features.pictureInPicture"
class="align-self-center active-button"
icon
@click="togglePictureInPicture"
>
<v-icon>mdi-picture-in-picture-bottom-right</v-icon>
</v-btn>
<v-btn
v-if="$features.fullScreen"
class="align-self-center active-button"
icon
disabled
@click="toggleFullScreen"
>
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</div>
</div>
</div>
@ -188,15 +183,6 @@ export default Vue.extend({
},
isPaused(): boolean {
return this.status === PlaybackStatus.paused;
},
currentItemName(): string {
switch (this.getCurrentItem.Type) {
case 'Episode':
return `${this.getCurrentItem.SeriesName} - S${this.getCurrentItem.ParentIndexNumber}E${this.getCurrentItem.IndexNumber} - ${this.getCurrentItem.Name}`;
case 'Movie':
default:
return this.getCurrentItem.Name;
}
}
},
mounted() {
@ -213,123 +199,6 @@ export default Vue.extend({
window.addEventListener('keydown', this.handleKeyPress);
}
break;
case 'playbackManager/INCREASE_QUEUE_INDEX':
case 'playbackManager/DECREASE_QUEUE_INDEX':
case 'playbackManager/SET_CURRENT_ITEM_INDEX':
// Report playback stop for the previous item
if (
state.playbackManager.currentTime !== null &&
this.getPreviousItem?.Id
) {
this.$api.playState.reportPlaybackStopped(
{
playbackStopInfo: {
ItemId: this.getPreviousItem.Id,
PlaySessionId: state.playbackManager.playSessionId,
PositionTicks: this.msToTicks(
state.playbackManager.currentTime * 1000
)
}
},
{ progress: false }
);
}
// Then report the start of the next one
if (this.getCurrentItem?.Id) {
this.$api.playState.reportPlaybackStart(
{
playbackStartInfo: {
CanSeek: true,
ItemId: this.getCurrentItem.Id,
PlaySessionId: state.playbackManager.playSessionId,
MediaSourceId: state.playbackManager.currentMediaSource?.Id,
AudioStreamIndex:
state.playbackManager.currentAudioStreamIndex,
SubtitleStreamIndex:
state.playbackManager.currentSubtitleStreamIndex
}
},
{ progress: false }
);
this.updateMetadata();
}
this.setLastProgressUpdate({ progress: new Date().getTime() });
break;
case 'playbackManager/SET_CURRENT_TIME': {
if (state.playbackManager.status === PlaybackStatus.playing) {
const now = new Date().getTime();
if (
this.getCurrentItem !== null &&
now - state.playbackManager.lastProgressUpdate > 1000 &&
state.playbackManager.currentTime !== null
) {
this.$api.playState.reportPlaybackProgress(
{
playbackProgressInfo: {
ItemId: this.getCurrentItem.Id,
PlaySessionId: state.playbackManager.playSessionId,
IsPaused: false,
PositionTicks: Math.round(
this.msToTicks(state.playbackManager.currentTime * 1000)
)
}
},
{ progress: false }
);
this.setLastProgressUpdate({ progress: new Date().getTime() });
}
}
break;
}
case 'playbackManager/STOP_PLAYBACK':
if (state.playbackManager.currentTime !== null) {
this.$api.playState.reportPlaybackStopped(
{
playbackStopInfo: {
ItemId: this.getPreviousItem.Id,
PlaySessionId: state.playbackManager.playSessionId,
PositionTicks: this.msToTicks(
state.playbackManager.currentTime * 1000
)
}
},
{ progress: false }
);
this.setLastProgressUpdate({ progress: 0 });
this.resetMetadata();
this.removeMediaHandlers();
}
break;
case 'playbackManager/PAUSE_PLAYBACK':
if (state.playbackManager.currentTime !== null) {
this.$api.playState.reportPlaybackProgress(
{
playbackProgressInfo: {
ItemId: this.getCurrentItem.Id,
PlaySessionId: state.playbackManager.playSessionId,
IsPaused: true,
PositionTicks: Math.round(
this.msToTicks(state.playbackManager.currentTime * 1000)
)
}
},
{ progress: false }
);
this.setLastProgressUpdate({ progress: new Date().getTime() });
}
break;
}
});
@ -605,6 +474,20 @@ export default Vue.extend({
bottom: 0;
}
.controls-wrapper {
position: relative;
}
.player-controls {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.all-pointer-events {
pointer-events: all;
}
@ -661,18 +544,59 @@ export default Vue.extend({
.osd-top {
padding-bottom: 10em;
background: linear-gradient(
180deg,
rgba(16, 16, 16, 0.75) 0%,
rgba(16, 16, 16, 0) 100%
to bottom,
hsla(0, 0%, 0%, 0.75) 0%,
hsla(0, 0%, 0%, 0.74) 8.1%,
hsla(0, 0%, 0%, 0.714) 15.5%,
hsla(0, 0%, 0%, 0.672) 22.5%,
hsla(0, 0%, 0%, 0.618) 29%,
hsla(0, 0%, 0%, 0.556) 35.3%,
hsla(0, 0%, 0%, 0.486) 41.2%,
hsla(0, 0%, 0%, 0.412) 47.1%,
hsla(0, 0%, 0%, 0.338) 52.9%,
hsla(0, 0%, 0%, 0.264) 58.8%,
hsla(0, 0%, 0%, 0.194) 64.7%,
hsla(0, 0%, 0%, 0.132) 71%,
hsla(0, 0%, 0%, 0.078) 77.5%,
hsla(0, 0%, 0%, 0.036) 84.5%,
hsla(0, 0%, 0%, 0.01) 91.9%,
hsla(0, 0%, 0%, 0) 100%
);
}
.osd-bottom {
padding-top: 10em;
background: linear-gradient(
0deg,
rgba(16, 16, 16, 0.75) 0%,
rgba(16, 16, 16, 0) 100%
to top,
hsla(0, 0%, 0%, 0.75) 0%,
hsla(0, 0%, 0%, 0.74) 8.1%,
hsla(0, 0%, 0%, 0.714) 15.5%,
hsla(0, 0%, 0%, 0.672) 22.5%,
hsla(0, 0%, 0%, 0.618) 29%,
hsla(0, 0%, 0%, 0.556) 35.3%,
hsla(0, 0%, 0%, 0.486) 41.2%,
hsla(0, 0%, 0%, 0.412) 47.1%,
hsla(0, 0%, 0%, 0.338) 52.9%,
hsla(0, 0%, 0%, 0.264) 58.8%,
hsla(0, 0%, 0%, 0.194) 64.7%,
hsla(0, 0%, 0%, 0.132) 71%,
hsla(0, 0%, 0%, 0.078) 77.5%,
hsla(0, 0%, 0%, 0.036) 84.5%,
hsla(0, 0%, 0%, 0.01) 91.9%,
hsla(0, 0%, 0%, 0) 100%
);
}
.video-title {
height: 6em;
}
// HACK: https://github.com/vuetifyjs/vuetify/issues/8436.
// https://vuetifyjs.com/en/api/v-btn/#retain-focus-on-click prop was added
// but it seems we're using a prop combination that it's incompatible with it: NaN;
// SO link: https://stackoverflow.com/questions/57830767/is-it-default-for-vuetify-to-keep-active-state-on-buttons-after-click-how-do-yo/57831256#57831256
.active-button:focus::before {
opacity: 0 !important;
}
</style>

View File

@ -4,6 +4,7 @@
ref="videoPlayer"
:poster="poster.url"
autoplay
:playsinline="$browser.isMobile() && $browser.isApple()"
@timeupdate="onVideoProgressThrottled"
@pause="onVideoPause"
@play="onPlay"

View File

@ -97,6 +97,9 @@
:fab="!(opaqueAppBar || $vuetify.breakpoint.xsOnly) && !isScrolled"
bottom
/>
<cast-button
:fab="!(opaqueAppBar || $vuetify.breakpoint.xsOnly) && !isScrolled"
/>
</v-app-bar>
<v-main>
<div class="pa-s">

View File

@ -351,7 +351,7 @@
"switchToLightMode": "Přepnout světlý režim"
},
"trailer": "Upoutávka",
"tvShowAbbrev": "Řada {seasonNumber} díl {episodeNumber}",
"seasonEpisodeAbbrev": "Řada {seasonNumber} díl {episodeNumber}",
"type": "Typ",
"unableGetPublicUsers": "Načtení uživatelů se nezdařilo",
"unableGetRelated": "Načtení souvisejicích položek se nezdařilo",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "In den hellen Modus wechseln"
},
"trailer": "Trailer",
"tvShowAbbrev": "S{seasonNumber} F{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} F{episodeNumber}",
"type": "Typ",
"unableGetPublicUsers": "Benutzer können nicht abgerufen werden",
"unableGetRelated": "Verwandte Elemente konnten nicht abgerufen werden",

View File

@ -4,12 +4,14 @@
"actor": "Actor",
"actors": "Actors",
"addNewPerson": "Add a new person",
"airPlayDevices": "AirPlay devices",
"albums": "Albums",
"allLanguages": "All languages",
"alphabetically": "Alphabetically",
"architecture": "Architecture",
"artist": "Artist",
"artists": "Artists",
"aspectRatio": "Aspect ratio",
"audio": "Audio",
"badRequest": "Bad request. Try again",
"biography": "Biography",
@ -57,7 +59,9 @@
"filtersNotFound": "Unable to load filters",
"fullScreen": "Full screen",
"general": "General",
"genericJellyfinPlaceholderDevice": "Generic Jellyfin device",
"genres": "Genres",
"googleCastPlaceholderDevice": "Google Cast device",
"guestStar": "Guest Star",
"headerExternalIds": "External IDs",
"headerPaths": "Path",
@ -150,6 +154,12 @@
},
"manualLogin": "Manual login",
"menu": "Menu",
"metadata": {
"source": "Source",
"sourceAll": "All",
"title": "Title",
"type": "Type"
},
"metadataEditor": "Metadata editor",
"metadataNoResultsMatching": "No results matching \"{search}\". Press enter to create a new one.",
"more": "More",
@ -197,19 +207,25 @@
"shuffle": "Shuffle",
"shuffleAll": "Shuffle all"
},
"playbackData": "Playback data",
"playbackSettings": "Playback settings",
"played": "Played",
"present": "Present",
"producer": "Producer",
"quality": "Quality",
"queue": "Queue",
"rating": "Rating",
"refreshLibrary": "Refresh library",
"releaseDate": "Release date",
"remoteDevices": "Remote devices",
"resumable": "Resumable",
"resume": "Resume",
"role": "Role",
"save": "Save",
"saved": "Saved",
"search": "Search",
"seasonEpisode": "Season {seasonNumber} Episode {episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"series": "Series",
"server": "Server",
"serverVersion": "Server version",
@ -333,9 +349,11 @@
"sortByType": "By {type}",
"sortTitle": "Sort title",
"specialFeatures": "Special Features",
"speed": "Speed",
"status": "Status",
"studios": "Studios",
"subtitles": "Subtitles",
"syncPlayGroups": "SyncPlay groups",
"syncingInProgress": "Syncing in progress",
"tagline": "Tagline",
"tags": "Tags",
@ -347,7 +365,6 @@
"switchToLightMode": "Switch to light mode"
},
"trailer": "Trailer",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Type",
"unableGetPublicUsers": "Unable to get users",
"unableGetRelated": "Unable to get related items",
@ -396,11 +413,5 @@
"writing": "Writing",
"year": "Year",
"years": "Years",
"youMayAlsoLike": "You may also like",
"metadata": {
"source": "Source",
"sourceAll": "All",
"title": "Title",
"type": "Type"
}
"youMayAlsoLike": "You may also like"
}

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Cambiar al tema claro"
},
"trailer": "Trailer",
"tvShowAbbrev": "T{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "T{seasonNumber} E{episodeNumber}",
"type": "Tipo",
"unableGetPublicUsers": "Error al cargar los usuarios",
"unableGetRelated": "Error al cargar los elementos relacionados",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Cambiar a modo claro"
},
"trailer": "Adelanto",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Tipo",
"unableGetPublicUsers": "No se han podido obtener los usuarios",
"unableGetRelated": "No se han podido obtener elementos relacionados",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Basculer en mode clair"
},
"trailer": "Bande-annonce",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Type",
"unableGetPublicUsers": "Impossible de récupérer les utilisateurs",
"unableGetRelated": "Impossible de récupérer les éléments liés",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Váltás világos módra"
},
"trailer": "Előzetes",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Típus",
"unableGetPublicUsers": "Nem sikerült lekérni a felhasználókat",
"unableGetRelated": "Nem sikerült lekérni a kapcsolódó elemeket",

View File

@ -345,7 +345,7 @@
"switchToLightMode": "Beralih ke mode terang"
},
"trailer": "Cuplikan",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Tipe",
"unableGetPublicUsers": "Tidak bisa mendapatkan pengguna",
"unableGetRelated": "Tak bisa mendapatkan item terkait",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Jaryq rejımge auysu"
},
"trailer": "Treiler",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Türı",
"unableGetPublicUsers": "Paidalanuşylardy alu mümkın emes",
"unableGetRelated": "Qatysty tarmaqtardy alu mümkın emes",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "ലൈറ്റ് മോഡിലേക്ക് മാറുക"
},
"trailer": "ട്രെയിലർ",
"tvShowAbbrev": "S {seasonNumber} E {episodeNumber}",
"seasonEpisodeAbbrev": "S {seasonNumber} E {episodeNumber}",
"type": "തരം",
"unableGetPublicUsers": "ഉപയോക്താക്കളെ നേടാനായില്ല",
"unableGetRelated": "അനുബന്ധ ഇനങ്ങൾ നേടാനായില്ല",

View File

@ -345,7 +345,7 @@
"switchToLightMode": "Bytt til lys modus"
},
"trailer": "Trailer",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Type",
"unableGetPublicUsers": "Kunne ikke hente brukerliste",
"unableGetRelated": "Kunne ikke hente relaterte artikler",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Schakel over naar de lichtmodus"
},
"trailer": "Trailer",
"tvShowAbbrev": "S{seasonNumber} A{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} A{episodeNumber}",
"type": "Type",
"unableGetPublicUsers": "Gebruikers laden is mislukt",
"unableGetRelated": "Kan gerelateerde items niet ophalen",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Przełącz na tryb jasny"
},
"trailer": "Zwiastun",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Typ",
"unableGetPublicUsers": "Nie udało się odczytać użytkowników",
"unableGetRelated": "Nie udało się odczytać powiązanych elementów",

View File

@ -317,7 +317,7 @@
"switchToLightMode": "Trocar para modo claro"
},
"trailer": "Trailer",
"tvShowAbbrev": "T{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "T{seasonNumber} E{episodeNumber}",
"type": "Tipo",
"upNext": "A seguir",
"upNextName": "A seguir: {upNextItemName}",

View File

@ -352,7 +352,7 @@
"switchToLightMode": "Treceți la modul luminos"
},
"trailer": "Trailer",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Tip",
"unableGetPublicUsers": "Nu se pot obține utilizatorii",
"unableGetRelated": "Nu se pot obține articole conexe",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Перейти на светлый режим"
},
"trailer": "Трейлер",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Тип",
"unableGetPublicUsers": "Невозможно получить пользователей",
"unableGetRelated": "Невозможно получить связанные элементы",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "ஒளி பயன்முறைக்கு மாறவும்"
},
"trailer": "டிரெய்லர்",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "வகை",
"unableGetPublicUsers": "பயனர்களைப் பெற முடியவில்லை",
"unableGetRelated": "தொடர்புடைய பொருட்களைப் பெற முடியவில்லை",

View File

@ -353,7 +353,7 @@
"switchToLightMode": "Chuyển sang chế độ sáng"
},
"trailer": "Đoạn Giới Thiệu",
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
"type": "Kiểu",
"unableGetPublicUsers": "Không thể lấy được thông tin người dùng",
"unableGetRelated": "Không thể lấy các mục liên quan",

View File

@ -352,7 +352,7 @@
"switchToLightMode": "切换到浅色模式"
},
"trailer": "预告片",
"tvShowAbbrev": "季 {seasonNumber} 章 {episodeNumber}",
"seasonEpisodeAbbrev": "季 {seasonNumber} 章 {episodeNumber}",
"type": "类型",
"unableGetPublicUsers": "无法获取用户",
"unableGetRelated": "无法获取相关项目",

View File

@ -205,5 +205,5 @@
"switchToLightMode": "切換成淺色模式"
},
"trailer": "預告片",
"tvShowAbbrev": "第 {seasonNumber} 季第 {episodeNumber} 章"
"seasonEpisodeAbbrev": "第 {seasonNumber} 季第 {episodeNumber} 章"
}

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { NuxtConfig } from '@nuxt/types';
import webpack from 'webpack';
import simpleIcons from 'simple-icons';
const config: NuxtConfig = {
/*
@ -331,6 +332,12 @@ const config: NuxtConfig = {
options: {
customProperties: true
}
},
icons: {
iconfont: 'mdi',
values: {
jellyfin: simpleIcons.get('jellyfin').path
}
}
},
loadingIndicator: {
@ -352,9 +359,6 @@ const config: NuxtConfig = {
}
},
optimizeCSS: true,
extractCSS: {
ignoreOrder: true
},
babel: {
// envName: server, client, modern
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -3,6 +3,9 @@ import { Plugin } from '@nuxt/types';
export interface SupportedFeatures {
pictureInPicture: boolean;
airPlay: boolean;
googleCast: boolean;
playbackRate: boolean;
fullScreen: boolean;
}
declare module '@nuxt/types' {
@ -31,11 +34,40 @@ declare module 'vuex/types/index' {
const supportedFeaturesPlugin: Plugin = ({ $browser }, inject) => {
const supportedFeatures: SupportedFeatures = {
pictureInPicture: false,
airPlay: false
airPlay: false,
googleCast: false,
playbackRate: false,
fullScreen: false
};
const video = document.createElement('video');
/**
* Detects if the current platform supports showing fullscreen videos
*
* @returns {boolean}
*/
function supportsFullscreen(): boolean {
// TVs don't support fullscreen. iOS, when user through the PWA, is already full screen.
if ($browser.isTv() || ($browser.isApple() && $browser.isMobile())) {
return false;
}
const element = document.documentElement;
return !!(
element.requestFullscreen ||
// @ts-expect-error -- Non-standard property
element.mozRequestFullScreen ||
// @ts-expect-error -- Non-standard property
element.webkitRequestFullscreen ||
// @ts-expect-error -- Non-standard property
element.msRequestFullscreen ||
// @ts-expect-error -- Non-standard property
document.createElement('video').webkitEnterFullscreen
);
}
if (
// Check non-standard Safari PiP support
// @ts-expect-error - Non-standard functions doesn't have typings
@ -55,6 +87,21 @@ const supportedFeaturesPlugin: Plugin = ({ $browser }, inject) => {
supportedFeatures.airPlay = true;
}
if (
$browser.isChrome() ||
($browser.isEdge() && $browser.isChromiumBased())
) {
supportedFeatures.googleCast = true;
}
if (supportsFullscreen()) {
supportedFeatures.fullScreen = true;
}
if (typeof video.playbackRate === 'number') {
supportedFeatures.playbackRate = true;
}
inject('features', supportedFeatures);
};

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import { MutationTree, ActionTree } from 'vuex';
import { UserDto } from '@jellyfin/client-axios';
import { UserDto } from '@jellyfin/client-axios/models/user-dto';
// Modules
import { TvShowsState } from './tvShows';
import { ServerState } from './servers';
import { PageState } from './page';
@ -12,12 +13,19 @@ import { PlaybackManagerState } from './playbackManager';
import { BackdropState } from './backdrop';
import { DeviceState } from './deviceProfile';
import { ClientSettingsState } from './clientSettings';
import { websocketPlugin } from './plugins/websocket';
import { ItemsState } from './items';
// Vuex plugins
import { websocketPlugin } from './plugins/websocketPlugin';
import { playbackReportingPlugin } from './plugins/playbackReportingPlugin';
import { preferencesSync } from './plugins/preferencesSyncPlugin';
import { userPlugin } from './plugins/userPlugin';
import { ItemsState } from './items';
export const plugins = [websocketPlugin, preferencesSync, userPlugin];
export const plugins = [
websocketPlugin,
playbackReportingPlugin,
preferencesSync,
userPlugin
];
export interface AuthState {
busy: boolean;

View File

@ -67,7 +67,7 @@ export const defaultState = (): PlaybackManagerState => ({
isFullscreen: false,
isMuted: false,
isShuffling: false,
isMinimized: true,
isMinimized: false,
repeatMode: RepeatMode.RepeatNone,
queue: [],
originalQueue: [],
@ -126,6 +126,13 @@ export const getters: GetterTree<PlaybackManagerState, PlaybackManagerState> = {
}
return null;
},
getCurrentItemSubtitleTracks: (state) => {
if (state.currentMediaSource !== null) {
return state.currentMediaSource.MediaStreams?.filter((stream) => {
return stream.Type === 'Subtitle';
});
}
}
};

View File

@ -0,0 +1,129 @@
import { Plugin } from 'vuex';
import { AppState } from '..';
import { PlaybackStatus } from '../playbackManager';
import { msToTicks } from '~/mixins/timeUtils';
export const playbackReportingPlugin: Plugin<AppState> = (store) => {
store.subscribe((mutation, state: AppState) => {
switch (mutation.type) {
case 'playbackManager/INCREASE_QUEUE_INDEX':
case 'playbackManager/DECREASE_QUEUE_INDEX':
case 'playbackManager/SET_CURRENT_ITEM_INDEX':
// Report playback stop for the previous item
if (
state.playbackManager.currentTime !== null &&
store.getters['playbackManager/getPreviousItem']
) {
store.$api.playState.reportPlaybackStopped(
{
playbackStopInfo: {
ItemId: store.getters['playbackManager/getPreviousItem']?.Id,
PlaySessionId: state.playbackManager.playSessionId,
PositionTicks: msToTicks(
state.playbackManager.currentTime * 1000
)
}
},
{ progress: false }
);
}
// Then report the start of the next one
if (store.getters['playbackManager/getCurrentItem']) {
store.$api.playState.reportPlaybackStart(
{
playbackStartInfo: {
CanSeek: true,
ItemId: store.getters['playbackManager/getCurrentItem']?.Id,
PlaySessionId: state.playbackManager.playSessionId,
MediaSourceId: state.playbackManager.currentMediaSource?.Id,
AudioStreamIndex: state.playbackManager.currentAudioStreamIndex,
SubtitleStreamIndex:
state.playbackManager.currentSubtitleStreamIndex
}
},
{ progress: false }
);
}
store.dispatch('playbackManager/setLastProgressUpdate', {
progress: new Date().getTime()
});
break;
case 'playbackManager/SET_CURRENT_TIME': {
if (state.playbackManager.status === PlaybackStatus.playing) {
const now = new Date().getTime();
if (
store.getters['playbackManager/getCurrentItem'] &&
now - state.playbackManager.lastProgressUpdate > 1000 &&
state.playbackManager.currentTime !== null
) {
store.$api.playState.reportPlaybackProgress(
{
playbackProgressInfo: {
ItemId: store.getters['playbackManager/getCurrentItem']?.Id,
PlaySessionId: state.playbackManager.playSessionId,
IsPaused: false,
PositionTicks: Math.round(
msToTicks(state.playbackManager.currentTime * 1000)
)
}
},
{ progress: false }
);
store.dispatch('playbackManager/setLastProgressUpdate', {
progress: new Date().getTime()
});
}
}
break;
}
case 'playbackManager/STOP_PLAYBACK':
if (state.playbackManager.currentTime !== null) {
store.$api.playState.reportPlaybackStopped(
{
playbackStopInfo: {
ItemId: store.getters['playbackManager/getPreviousItem']?.Id,
PlaySessionId: state.playbackManager.playSessionId,
PositionTicks: msToTicks(
state.playbackManager.currentTime * 1000
)
}
},
{ progress: false }
);
store.dispatch('playbackManager/setLastProgressUpdate', {
progress: 0
});
}
break;
case 'playbackManager/PAUSE_PLAYBACK':
if (state.playbackManager.currentTime !== null) {
store.$api.playState.reportPlaybackProgress(
{
playbackProgressInfo: {
ItemId: store.getters['playbackManager/getCurrentItem']?.Id,
PlaySessionId: state.playbackManager.playSessionId,
IsPaused: true,
PositionTicks: Math.round(
msToTicks(state.playbackManager.currentTime * 1000)
)
}
},
{ progress: false }
);
store.dispatch('playbackManager/setLastProgressUpdate', {
progress: new Date().getTime()
});
}
break;
}
});
};

11
package-lock.json generated
View File

@ -3766,6 +3766,12 @@
"@types/node": "*"
}
},
"@types/simple-icons": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/@types/simple-icons/-/simple-icons-4.14.0.tgz",
"integrity": "sha512-eMt68v8qGF42I7SODwAV56/xatcFbFtFmwlN9DPEDcVEFCk4EJfPsDiw4e4CPCRtM5BmD0B1ikSTYvtjcWW+Dw==",
"dev": true
},
"@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@ -17454,6 +17460,11 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA=="
},
"simple-icons": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-4.18.0.tgz",
"integrity": "sha512-0ew6glkQN09tONjOzk75ofeAMDa7EKom5yGeh9nxkoInlEXoQX84XOuvWAhJoJPvRrjvJLSUzWOmRzSEMeyz8w=="
},
"simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",

View File

@ -52,6 +52,7 @@
"nuxt-vuex-localstorage": "^1.3.0",
"qs": "^6.10.1",
"shaka-player": "^3.0.10",
"simple-icons": "^4.18.0",
"swiper": "5.x",
"uuid": "^8.3.2",
"vee-validate": "^3.4.5",
@ -88,6 +89,7 @@
"@types/lodash": "^4.14.168",
"@types/nuxtjs__auth": "^4.8.7",
"@types/qs": "^6.9.6",
"@types/simple-icons": "^4.14.0",
"@types/swiper": "^5.4.2",
"@types/uuid": "^8.3.0",
"@types/wicg-mediasession": "^1.1.0",