mirror of
https://github.com/jellyfin/jellyfin-vue.git
synced 2024-10-07 03:23:37 +00:00
feat(video-osd): implement proper OSD design
Based on @Perseusdehond's design
This commit is contained in:
parent
5197af3f4b
commit
d7d11af2fc
@ -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 {
|
||||
|
108
client/components/Buttons/CastButton.vue
Normal file
108
client/components/Buttons/CastButton.vue
Normal 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>
|
123
client/components/Buttons/PlaybackSettingsButton.vue
Normal file
123
client/components/Buttons/PlaybackSettingsButton.vue
Normal 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>
|
@ -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;
|
||||
}
|
||||
|
||||
|
74
client/components/Buttons/SubtitleSelectionButton.vue
Normal file
74
client/components/Buttons/SubtitleSelectionButton.vue
Normal 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>
|
@ -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}`;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -4,6 +4,7 @@
|
||||
ref="videoPlayer"
|
||||
:poster="poster.url"
|
||||
autoplay
|
||||
:playsinline="$browser.isMobile() && $browser.isApple()"
|
||||
@timeupdate="onVideoProgressThrottled"
|
||||
@pause="onVideoPause"
|
||||
@play="onPlay"
|
||||
|
@ -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">
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -353,7 +353,7 @@
|
||||
"switchToLightMode": "ലൈറ്റ് മോഡിലേക്ക് മാറുക"
|
||||
},
|
||||
"trailer": "ട്രെയിലർ",
|
||||
"tvShowAbbrev": "S {seasonNumber} E {episodeNumber}",
|
||||
"seasonEpisodeAbbrev": "S {seasonNumber} E {episodeNumber}",
|
||||
"type": "തരം",
|
||||
"unableGetPublicUsers": "ഉപയോക്താക്കളെ നേടാനായില്ല",
|
||||
"unableGetRelated": "അനുബന്ധ ഇനങ്ങൾ നേടാനായില്ല",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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}",
|
||||
|
@ -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",
|
||||
|
@ -353,7 +353,7 @@
|
||||
"switchToLightMode": "Перейти на светлый режим"
|
||||
},
|
||||
"trailer": "Трейлер",
|
||||
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
|
||||
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
|
||||
"type": "Тип",
|
||||
"unableGetPublicUsers": "Невозможно получить пользователей",
|
||||
"unableGetRelated": "Невозможно получить связанные элементы",
|
||||
|
@ -353,7 +353,7 @@
|
||||
"switchToLightMode": "ஒளி பயன்முறைக்கு மாறவும்"
|
||||
},
|
||||
"trailer": "டிரெய்லர்",
|
||||
"tvShowAbbrev": "S{seasonNumber} E{episodeNumber}",
|
||||
"seasonEpisodeAbbrev": "S{seasonNumber} E{episodeNumber}",
|
||||
"type": "வகை",
|
||||
"unableGetPublicUsers": "பயனர்களைப் பெற முடியவில்லை",
|
||||
"unableGetRelated": "தொடர்புடைய பொருட்களைப் பெற முடியவில்லை",
|
||||
|
@ -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",
|
||||
|
@ -352,7 +352,7 @@
|
||||
"switchToLightMode": "切换到浅色模式"
|
||||
},
|
||||
"trailer": "预告片",
|
||||
"tvShowAbbrev": "季 {seasonNumber} 章 {episodeNumber}",
|
||||
"seasonEpisodeAbbrev": "季 {seasonNumber} 章 {episodeNumber}",
|
||||
"type": "类型",
|
||||
"unableGetPublicUsers": "无法获取用户",
|
||||
"unableGetRelated": "无法获取相关项目",
|
||||
|
@ -205,5 +205,5 @@
|
||||
"switchToLightMode": "切換成淺色模式"
|
||||
},
|
||||
"trailer": "預告片",
|
||||
"tvShowAbbrev": "第 {seasonNumber} 季第 {episodeNumber} 章"
|
||||
"seasonEpisodeAbbrev": "第 {seasonNumber} 季第 {episodeNumber} 章"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
129
client/store/plugins/playbackReportingPlugin.ts
Normal file
129
client/store/plugins/playbackReportingPlugin.ts
Normal 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
11
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user