mirror of
https://github.com/jellyfin/jellyfin-vue.git
synced 2025-03-04 19:57:34 +00:00
Merge pull request #342 from jellyfin/video-player-errors
Video player improvements (Errors, progress reporting) & imageMixin cleanup
This commit is contained in:
commit
f770ee3133
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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
36
mixins/modalHelper.ts
Normal 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;
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user