Merge pull request #342 from jellyfin/video-player-errors

Video player improvements (Errors, progress reporting) & imageMixin cleanup
This commit is contained in:
Julien Machiels 2020-12-14 13:01:43 +01:00 committed by GitHub
commit f770ee3133
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 257 additions and 51 deletions

View File

@ -62,9 +62,9 @@ export default Vue.extend({
if (this.item.ImageTags && this.item.ImageTags.Primary) {
const card = this.$refs.card as HTMLElement;
this.image = this.getImageUrlForElement(
card,
this.item,
ImageType.Primary
ImageType.Primary,
card
);
}
}

View File

@ -1,7 +1,16 @@
<template>
<v-container fill-height fluid class="pa-0">
<div ref="videoContainer">
<video ref="videoPlayer" :poster="poster" autoplay></video>
<video
ref="videoPlayer"
:poster="poster"
autoplay
@playing="onVideoPlaying"
@timeupdate="onVideoProgress"
@pause="onVideoPause"
@play="onVideoProgress"
@ended="onVideoStopped"
></video>
</div>
</v-container>
</template>
@ -17,6 +26,8 @@ import shaka from 'shaka-player/dist/shaka-player.ui';
import muxjs from 'mux.js';
import { mapActions } from 'vuex';
import 'shaka-player/dist/controls.css';
import { PlaybackInfoResponse } from '@jellyfin/client-axios';
import timeUtils from '~/mixins/timeUtils';
declare global {
interface Window {
@ -26,6 +37,7 @@ declare global {
}
export default Vue.extend({
mixins: [timeUtils],
props: {
item: {
type: Object,
@ -38,6 +50,8 @@ export default Vue.extend({
},
data() {
return {
playbackInfo: {} as PlaybackInfoResponse,
lastProgressUpdate: 0,
source: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
player: null as any,
@ -51,27 +65,24 @@ export default Vue.extend({
try {
await this.player.load(newSource);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error code', e.code, 'object', e);
this.pushSnackbarMessage({
message: this.$t('unexpectedError'),
error: 'error'
});
// No need to actually process the error here, the error handler will do this for us
}
}
}
},
async mounted() {
try {
const response = await this.$api.mediaInfo.getPostedPlaybackInfo({
itemId: this.$route.params.itemId,
userId: this.$auth.user.Id,
...this.$playbackProfile
});
this.playbackInfo = (
await this.$api.mediaInfo.getPostedPlaybackInfo({
itemId: this.$route.params.itemId,
userId: this.$auth.user.Id,
playbackInfoDto: { DeviceProfile: this.$playbackProfile }
})
).data;
let mediaSource;
if (response?.data?.MediaSources) {
mediaSource = response.data.MediaSources[0];
if (this.playbackInfo?.MediaSources) {
mediaSource = this.playbackInfo.MediaSources[0];
} else {
throw new Error("This item can't be played.");
}
@ -113,6 +124,8 @@ export default Vue.extend({
this.$refs.videoContainer,
this.$refs.videoPlayer
);
// Register player events
this.player.addEventListener('error', this.onPlayerError);
} else {
this.$nuxt.error({
message: this.$t('browserNotSupported') as string
@ -128,12 +141,99 @@ export default Vue.extend({
beforeDestroy() {
if (this.player) {
window.muxjs = undefined;
this.onVideoStopped(); // Report that the playback is stopping
this.player.removeEventListener('error', this.onPlayerError);
this.player.unload();
this.player.destroy();
}
},
methods: {
...mapActions('snackbar', ['pushSnackbarMessage'])
...mapActions('snackbar', ['pushSnackbarMessage']),
onVideoPlaying(_event: Event) {
// TODO: Move to playback manager
this.$api.playState.reportPlaybackStart(
{
playbackStartInfo: {
CanSeek: true,
ItemId: this.item.Id,
PlaySessionId: this.playbackInfo.PlaySessionId,
MediaSourceId: this.playbackInfo.MediaSources?.[0].Id,
AudioStreamIndex: 0, // TODO: Don't hardcode this
SubtitleStreamIndex: 0 // TODO: Don't hardcode this
}
},
{ progress: false }
);
this.lastProgressUpdate = new Date().getTime();
},
onVideoProgress(_event?: Event) {
// TODO: Move to playback manager
const now = new Date().getTime();
if (now - this.lastProgressUpdate > 1000) {
const currentTime = (this.$refs.videoPlayer as HTMLVideoElement)
.currentTime;
this.$api.playState.reportPlaybackProgress(
{
playbackProgressInfo: {
ItemId: this.item.Id,
PlaySessionId: this.playbackInfo.PlaySessionId,
IsPaused: false,
PositionTicks: Math.round(this.msToTicks(currentTime * 1000))
}
},
{ progress: false }
);
this.lastProgressUpdate = new Date().getTime();
}
},
onVideoPause(_event?: Event) {
// TODO: Move to playback manager
const currentTime = (this.$refs.videoPlayer as HTMLVideoElement)
.currentTime;
this.$api.playState.reportPlaybackProgress(
{
playbackProgressInfo: {
ItemId: this.item.Id,
PlaySessionId: this.playbackInfo.PlaySessionId,
IsPaused: true,
PositionTicks: Math.round(this.msToTicks(currentTime * 1000))
}
},
{ progress: false }
);
},
onVideoStopped(_event?: Event) {
// TODO: Move to playback manager
const currentTime = (this.$refs.videoPlayer as HTMLVideoElement)
.currentTime;
this.$api.playState.reportPlaybackStopped(
{
playbackStopInfo: {
ItemId: this.item.Id,
PlaySessionId: this.playbackInfo.PlaySessionId,
PositionTicks: this.msToTicks(currentTime * 1000)
}
},
{ progress: false }
);
this.lastProgressUpdate = 0;
if (event !== undefined) {
// We're coming from a real end of playback event, so avoid staying on the video screen after playback
// TODO: Once in the playback manager, move this to the end of the queue
this.$router.back();
}
},
onPlayerError(event: Event) {
this.$emit('error', event);
}
}
});
</script>

View File

@ -7,6 +7,9 @@
"badRequest": "Bad request. Try again",
"biography": "Biography",
"browserNotSupported": "Your browser is not supported for playing this file.",
"buttons": {
"ok": "Ok"
},
"byArtist": "By",
"changeServer": "Change server",
"changeUser": "Change user",
@ -19,6 +22,13 @@
"dislikes": "Dislikes",
"endDate": "End date",
"endsAt": "Ends at {time}",
"errors": {
"anErrorHappened": "An error happened",
"messages": {
"errorCode": "Error code: {errorCode}",
"videoPlayerError": "The video player encountered an unrecoverable error."
}
},
"episodeNumber": "Episode {episodeNumber}",
"failedToRefreshItems": "Failed to refresh items",
"favorite": "Favorite",
@ -47,9 +57,8 @@
"networks": "Networks",
"nextUp": "Next up",
"noNetworkConnection": "No network connection",
"noResultsFound": "No results found",
"numberTracks": "{number} tracks",
"noResultsFound": "There is nothing here",
"numberTracks": "{number} tracks",
"parentalRatings": "Parental Ratings",
"password": "Password",
"play": "Play",

View File

@ -9,48 +9,37 @@ import { BaseItemDto, ImageType } from '@jellyfin/client-axios';
declare module '@nuxt/types' {
interface Context {
getImageUrl: (id: string, type: string) => string;
getImageUrlForElement: (
element: HTMLElement,
item: BaseItemDto,
type: ImageType
type: ImageType,
element?: HTMLElement,
limitByWidth?: boolean
) => string;
}
interface NuxtAppOptions {
getImageUrl: (id: string, type: string) => string;
getImageUrlForElement: (
element: HTMLElement,
item: BaseItemDto,
type: ImageType
type: ImageType,
element?: HTMLElement,
limitByWidth?: boolean
) => string;
}
}
declare module 'vue/types/vue' {
interface Vue {
getImageUrl: (id: string, type: string) => string;
getImageUrlForElement: (
element: HTMLElement,
item: BaseItemDto,
type: ImageType
type: ImageType,
element?: HTMLElement,
limitByWidth?: boolean
) => string;
}
}
const imageHelper = Vue.extend({
methods: {
// TODO: Merge getImageUrl and getImageUrlForElement
/**
* Returns the URL of an item's image without any options
*
* @param {string} id - itemId to get image for
* @param {string} type - type of image (primary/backdrop)
* @returns {string} URL of the link to the image
*/
getImageUrl(id: string, type: string): string {
return `${this.$axios.defaults.baseURL}/Items/${id}/Images/${type}`;
},
/**
* Returns the URL of an item's image at a specific size.
*
@ -61,32 +50,60 @@ const imageHelper = Vue.extend({
* @returns {string} The URL for the image, with the base URL set and the options provided.
*/
getImageUrlForElement(
element: HTMLElement,
item: BaseItemDto,
type: ImageType,
element?: HTMLElement,
limitByWidth = false
): string {
// TODO: Refactor this with an options object
if (!item) {
throw new TypeError('item must not be null or undefined');
}
if (!item.ImageTags) {
throw new TypeError('item.ImageTags must not be null or undefined');
let itemId;
if (item.Type === 'Episode' && type === ImageType.Thumb) {
itemId = item.SeriesId;
if (item.SeriesThumbImageTag) {
type = ImageType.Thumb;
} else {
type = ImageType.Backdrop;
}
} else if (item.Type === 'Episode' && type === ImageType.Backdrop) {
itemId = item.SeriesId;
} else if (item.Type === 'Audio' && type === ImageType.Backdrop) {
itemId = item.AlbumArtists?.[0].Id;
} else {
itemId = item.Id;
}
const url = new URL(
`${this.$axios.defaults.baseURL}/Items/${item.Id}/Images/${type}`
`${this.$axios.defaults.baseURL}/Items/${itemId}/Images/${type}`
);
let imageTag;
if (item.Type === 'Episode' && type === ImageType.Thumb) {
if (item.SeriesThumbImageTag) {
imageTag = item.SeriesThumbImageTag;
} else {
imageTag = item.ParentBackdropImageTags?.[0];
}
} else if (item.Type === 'Episode' && type === ImageType.Backdrop) {
imageTag = item.ParentBackdropImageTags?.[0];
} else {
imageTag = item.ImageTags?.[type];
}
const params: { [k: string]: string | number | undefined } = {
tag: item.ImageTags[type],
tag: imageTag,
quality: 90
};
if (limitByWidth) {
if (element && limitByWidth) {
params.maxWidth = element.clientWidth.toString();
} else {
} else if (element) {
params.maxHeight = element.clientHeight.toString();
}
url.search = stringify(params);
return url.toString();

36
mixins/modalHelper.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* Helpers for modals
*
* @mixin
*/
import Vue from 'vue';
declare module '@nuxt/types' {
interface Context {
smallModalWidth: () => string | number;
}
interface NuxtAppOptions {
smallModalWidth: () => string | number;
}
}
declare module 'vue/types/vue' {
interface Vue {
smallModalWidth: () => string | number;
}
}
const modalHelper = Vue.extend({
computed: {
smallModalWidth(): string | number {
if (this.$vuetify.breakpoint.smAndDown) {
return '90%';
} else {
return 600;
}
}
}
});
export default modalHelper;

View File

@ -4,22 +4,52 @@
v-if="item.MediaType === 'Video' || item.MediaType === 'Audio'"
:item="item"
:poster="poster"
@error="handleShakaPlayerError"
/>
<v-dialog v-model="errorDialog" :width="smallModalWidth">
<v-card>
<v-card-title class="headline">
{{ $t('errors.anErrorHappened') }}
</v-card-title>
<v-card-text>
<p>{{ $t('errors.messages.videoPlayerError') }}</p>
<p class="mb-0">
{{ $t('errors.messages.errorCode', { errorCode }) }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="dismissError">
{{ $t('buttons.ok') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue';
import { BaseItemDto } from '@jellyfin/client-axios';
import { Route } from 'vue-router';
import { BaseItemDto, ImageType } from '@jellyfin/client-axios';
import modalHelper from '~/mixins/modalHelper';
import imageHelper from '~/mixins/imageHelper';
export default Vue.extend({
mixins: [imageHelper],
mixins: [imageHelper, modalHelper],
layout: 'fullpage',
data() {
return {
poster: '',
item: [] as BaseItemDto
errorCode: '',
errorDialog: false,
fromRoute: null as null | Route,
item: [] as BaseItemDto,
poster: ''
};
},
async beforeMount() {
@ -35,13 +65,27 @@ export default Vue.extend({
throw new Error('Item not found');
}
this.poster = this.getImageUrl(this.$route.params.itemId, 'backdrop');
this.poster = this.getImageUrlForElement(this.item, ImageType.Backdrop);
} catch (error) {
this.$nuxt.error({
statusCode: 404,
message: error
});
}
},
methods: {
handleShakaPlayerError(error: any) {
if (error?.detail?.severity === 1) {
// This error is recoverable, ignore for now
} else {
this.errorCode = error?.detail?.code;
this.errorDialog = true;
}
},
dismissError() {
this.errorDialog = false;
this.$router.back();
}
}
});
</script>