feat(video-osd): add playback information

This commit is contained in:
MrTimscampi
2021-06-22 19:24:18 +02:00
committed by Julien Machiels
parent bbf81795fb
commit ca521dff5a
7 changed files with 544 additions and 5 deletions
@@ -20,7 +20,6 @@
<v-btn
class="align-self-center active-button"
icon
disabled
v-bind="attrs"
v-on="{ ...tooltip, ...menu }"
>
@@ -92,7 +91,7 @@
</v-col>
</v-row>
</v-list-item>
<v-list-item>
<v-list-item @click.stop="$emit('open-playback-data')">
<v-list-item-content>
{{ $t('playbackData') }}
</v-list-item-content>
@@ -0,0 +1,417 @@
<template>
<v-container
v-if="sessionInfo"
class="playback-data-dialog pointer-events-none pa-lg-6"
>
<v-row>
<v-col cols="12" md="6" lg="4" xl="3">
<v-card class="mt-12 pb-6">
<v-card-title class="pb-2">
{{ $t('playbackInfo.name') }}
<v-spacer />
<v-btn
class="pointer-events-all"
icon
@click="$emit('close-playback-data')"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="py-0">
<div v-if="getPlayMethod" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playbackInfo.playMethod.name')"
/>
<div>{{ getPlayMethod }}</div>
</div>
<div v-if="getStreamType" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playbackInfo.streamType.name')"
/>
<div>{{ getStreamType }}</div>
</div>
</v-card-text>
<v-card-title v-if="playerStats" class="py-2">
{{ $t('playerInfo.name') }}
</v-card-title>
<v-card-text v-if="playerStats" class="py-0">
<div v-if="playerStats.width && playerStats.height" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playerInfo.videoResolution.name')"
/>
<div>
{{
$t('playerInfo.videoResolution.value', {
width: playerStats.width,
height: playerStats.height
})
}}
</div>
</div>
<div
v-if="videoDimensions.width && videoDimensions.height"
class="d-flex"
>
<div
class="font-weight-bold mr-2"
v-text="$t('playerInfo.playbackResolution.name')"
/>
<div>
{{ $t('playerInfo.videoResolution.value', videoDimensions) }}
</div>
</div>
<div v-if="!isNaN(playerStats.decodedFrames)" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playerInfo.decodedFrames.name')"
/>
<div>{{ playerStats.decodedFrames }}</div>
</div>
<div v-if="!isNaN(playerStats.corruptedFrames)" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playerInfo.droppedFrames.name')"
/>
<div>{{ playerStats.droppedFrames }}</div>
</div>
<div v-if="!isNaN(playerStats.corruptedFrames)" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playerInfo.corruptedFrames.name')"
/>
<div>{{ playerStats.corruptedFrames }}</div>
</div>
</v-card-text>
<v-card-title class="py-2">
{{ $t('mediaInfo.name') }}
</v-card-title>
<v-card-text class="py-0">
<div v-if="mediaContainer" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('mediaInfo.container.name')"
/>
<div>
{{ mediaContainer }}
</div>
</div>
<div v-if="mediaVideoCodec" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('mediaInfo.videoCodec.name')"
/>
<div>
{{ mediaVideoCodec }}
</div>
</div>
<div v-if="mediaAudioCodec" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('mediaInfo.audioCodec.name')"
/>
<div>
{{ mediaAudioCodec }}
</div>
</div>
<div v-if="mediaSubtitleCodec" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('mediaInfo.subtitleCodec.name')"
/>
<div>
{{ mediaSubtitleCodec }}
</div>
</div>
<div v-if="mediaAudioChannels" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('mediaInfo.audioChannels.name')"
/>
<div>
{{ mediaAudioChannels }}
</div>
</div>
<div v-if="mediaTotalBitrate" class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('mediaInfo.bitrate.name')"
/>
<div>
{{ mediaTotalBitrate }}
</div>
</div>
</v-card-text>
<v-card-title v-if="isTranscoding" class="py-2">
{{ $t('transcodingInfo.name') }}
</v-card-title>
<v-card-text class="py-0">
<div
v-if="
isTranscoding &&
sessionInfo.TranscodingInfo &&
sessionInfo.TranscodingInfo.TranscodeReasons.length > 0
"
class="d-flex"
>
<div
class="font-weight-bold mr-2"
v-text="
$tc(
'playbackInfo.transcodingReason.name',
sessionInfo.TranscodingInfo.TranscodeReasons.length
)
"
/>
<div class="d-flex flex-column">
<div
v-for="(reason, index) in sessionInfo.TranscodingInfo
.TranscodeReasons"
:key="index"
>
{{ $t(camelCase(reason)) }}
</div>
</div>
</div>
<div
v-if="
sessionInfo.TranscodingInfo &&
sessionInfo.TranscodingInfo.Framerate
"
class="d-flex"
>
<div
class="font-weight-bold mr-2"
v-text="$t('playbackInfo.transcodingFramerate.name')"
/>
<div>
{{
$t('playbackInfo.transcodingFramerate.value', {
value: sessionInfo.TranscodingInfo.Framerate
})
}}
</div>
</div>
<div
v-if="
sessionInfo.TranscodingInfo &&
sessionInfo.TranscodingInfo.CompletionPercentage
"
class="d-flex flex-column"
>
<div class="d-flex">
<div
class="font-weight-bold mr-2"
v-text="$t('playbackInfo.transcodingProgress')"
/>
<div>
{{
sessionInfo.TranscodingInfo.CompletionPercentage.toFixed(
1
) + '%'
}}
</div>
</div>
<v-progress-linear
class="d-inline-block mt-2"
:value="
sessionInfo.TranscodingInfo.CompletionPercentage.toFixed(1)
"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { SessionInfo } from '@jellyfin/client-axios';
import Vue from 'vue';
import { mapGetters, mapState } from 'vuex';
import { camelCase } from 'camel-case';
export default Vue.extend({
data() {
return {
updateSessionInterval: null as number | null,
sessionInfo: null as SessionInfo | null,
playerStats: {},
videoDimensions: { width: 0, height: 0 }
};
},
computed: {
...mapState('deviceProfile', ['deviceId']),
...mapState('playbackManager', ['currentMediaSource']),
...mapGetters('playbackManager', [
'getCurrentVideoTrack',
'getCurrentAudioTrack',
'getCurrentSubtitleTrack'
]),
isTranscoding(): boolean {
return !!(
this.sessionInfo?.PlayState?.PlayMethod === 'Transcode' ||
this.sessionInfo?.TranscodingInfo
);
},
getStreamType(): string {
if (window.player?.getAssetUri()) {
if (window.player.getAssetUri().includes('.m3u8')) {
return this.$t('playbackInfo.streamType.hls');
} else if (window.player.getAssetUri().includes('.mpd')) {
return this.$t('playbackInfo.streamType.dash');
} else {
return this.$t('playbackInfo.streamType.video');
}
}
return this.$t('playbackInfo.streamType.unknown');
},
getPlayMethod(): string {
if (this.sessionInfo?.PlayState?.PlayMethod === 'Transcode') {
return this.$t('playbackInfo.playMethod.transcode');
} else if (this.sessionInfo?.PlayState?.PlayMethod === 'DirectStream') {
return this.$t('playbackInfo.playMethod.remux');
} else if (this.sessionInfo?.PlayState?.PlayMethod === 'DirectPlay') {
return this.$t('playbackInfo.playMethod.direct');
}
return '';
},
mediaContainer(): string | null | undefined {
if (
this.sessionInfo?.TranscodingInfo?.Container &&
this.sessionInfo.TranscodingInfo.Container !==
this.sessionInfo?.NowPlayingItem?.Container
) {
return `${this.sessionInfo?.NowPlayingItem?.Container} ${this.sessionInfo?.TranscodingInfo?.Container}`;
}
return this.sessionInfo?.NowPlayingItem?.Container;
},
mediaVideoCodec(): string | null | undefined {
if (this.getCurrentVideoTrack) {
if (
(this.sessionInfo?.TranscodingInfo?.VideoCodec &&
this.getCurrentVideoTrack.Codec !==
this.sessionInfo.TranscodingInfo.VideoCodec) ||
!this.sessionInfo?.TranscodingInfo?.IsAudioDirect
) {
return `${this.getCurrentVideoTrack.Codec} ${this.sessionInfo?.TranscodingInfo?.VideoCodec}`;
}
return this.getCurrentVideoTrack.Codec;
}
return null;
},
mediaAudioCodec(): string | null | undefined {
if (this.getCurrentAudioTrack) {
if (
(this.sessionInfo?.TranscodingInfo?.AudioCodec &&
this.getCurrentAudioTrack.Codec !==
this.sessionInfo.TranscodingInfo.AudioCodec) ||
!this.sessionInfo?.TranscodingInfo?.IsAudioDirect
) {
return `${this.getCurrentAudioTrack.Codec} ${this.sessionInfo?.TranscodingInfo?.AudioCodec}`;
}
return this.getCurrentAudioTrack.Codec;
}
return null;
},
mediaSubtitleCodec(): string | null | undefined {
return this.getCurrentSubtitleTrack?.Codec;
},
mediaAudioChannels(): string | null | undefined {
if (
this.sessionInfo?.TranscodingInfo?.AudioChannels &&
this.getCurrentAudioTrack.Channels !==
this.sessionInfo.TranscodingInfo.AudioChannels
) {
return `${this.getCurrentAudioTrack.Channels} ${this.sessionInfo?.TranscodingInfo?.AudioChannels}`;
}
return this.getCurrentAudioTrack?.Channels;
},
mediaTotalBitrate(): string | null | undefined {
if (
this.sessionInfo?.TranscodingInfo?.Bitrate &&
this.sessionInfo.TranscodingInfo.Bitrate !==
this.currentMediaSource.Bitrate
) {
return `${this.getDisplayBitrate(
this.currentMediaSource.Bitrate
)} ${this.getDisplayBitrate(
this.sessionInfo?.TranscodingInfo?.Bitrate
)}`;
}
return this.getDisplayBitrate(this.currentMediaSource.Bitrate);
}
},
beforeMount() {
this.updateSessionInterval = window.setInterval(() => {
this.updateSession();
}, 1000);
},
beforeDestroy() {
if (this.updateSessionInterval !== null) {
window.clearInterval(this.updateSessionInterval);
}
},
methods: {
camelCase(value: string): string {
return camelCase(value);
},
getDisplayBitrate(bitrate: number): string {
if (bitrate > 1000000) {
return this.$t('formats.mbps', {
value: (bitrate / 1000000).toFixed(1)
});
} else {
return this.$t('formats.kbps', {
value: Math.floor(bitrate / 1000)
});
}
},
async updateSession(): Promise<void> {
this.sessionInfo = (
await this.$api.session.getSessions({
deviceId: this.deviceId
})
).data?.[0];
this.playerStats = window.player.getStats();
// Compute the displayed video's size
const videoRatio =
window.player.getMediaElement().videoWidth /
window.player.getMediaElement().videoHeight;
let width = window.player.getMediaElement().offsetWidth;
let height = window.player.getMediaElement().offsetHeight;
const elementRatio = width / height;
// If the video element is short and wide
if (elementRatio > videoRatio) {
width = height * videoRatio;
} else {
height = width / videoRatio;
}
this.videoDimensions = {
width: Math.floor(width),
height: Math.floor(height)
};
}
}
});
</script>
+15 -1
View File
@@ -19,6 +19,10 @@
:width="$vuetify.breakpoint.mobile ? '60vw' : '25vw'"
:value="isPlaying"
>
<playback-info-card
v-show="!isMinimized && playbackData"
@close-playback-data="playbackData = false"
/>
<v-hover v-slot="{ hover }">
<v-card class="player-card" width="100%">
<v-container fill-height fluid class="pa-0 justify-center">
@@ -205,6 +209,7 @@
<playback-settings-button
:nudge-top="$vuetify.breakpoint.mdAndUp ? 60 : 30"
@input="onQueueChangeHandler($event)"
@open-playback-data="onOpenPlaybackData"
/>
<v-btn
v-if="$features.pictureInPicture"
@@ -254,7 +259,8 @@ export default Vue.extend({
// eslint-disable-next-line @typescript-eslint/no-empty-function
unsubscribe(): void {},
fullScreenVideo: false,
keepOpen: false
keepOpen: false,
playbackData: false
};
},
computed: {
@@ -587,6 +593,9 @@ export default Vue.extend({
} else if (!value) {
this.setFullscreenTimeout();
}
},
onOpenPlaybackData(): void {
this.playbackData = true;
}
}
});
@@ -619,6 +628,11 @@ export default Vue.extend({
</style>
<style lang="scss" scoped>
.playback-data-dialog {
position: absolute;
z-index: 999;
}
.v-card.player-card {
background-color: black !important;
}
+4 -1
View File
@@ -28,6 +28,7 @@ declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
muxjs: any;
player: any;
}
}
@@ -101,7 +102,9 @@ export default Vue.extend({
shaka.polyfill.installAll();
if (shaka.Player.isBrowserSupported()) {
this.player = new shaka.Player(this.$refs.shakaPlayer);
// We use a global for ease of debugging and to fetch data from the playback information popup
window.player = new shaka.Player(this.$refs.shakaPlayer);
this.player = window.player;
// Register player events
this.player.addEventListener('error', this.onPlayerError);
+76 -1
View File
@@ -13,6 +13,7 @@
"artists": "Artists",
"aspectRatio": "Aspect ratio",
"audio": "Audio",
"audioCodecNotSupported": "The audio codec is not supported",
"badRequest": "Bad request. Try again",
"browserNotSupported": "Your browser is not supported for playing this file.",
"buttons": {
@@ -25,6 +26,7 @@
"communityRating": "Community rating",
"confirm": "Confirm",
"connect": "Connect",
"containerNotSupported": "The container is not supported",
"continueListening": "Continue listening",
"continueWatching": "Continue watching",
"criticRating": "Critic rating",
@@ -56,6 +58,10 @@
"features": "Features",
"filter": "Filter",
"filtersNotFound": "Unable to load filters",
"formats": {
"kbps": "{value} Kbps",
"mbps": "{value} Mbps"
},
"fullScreen": "Full screen",
"general": "General",
"genericJellyfinPlaceholderDevice": "Generic Jellyfin device",
@@ -159,6 +165,27 @@
"noLogsFound": "No logs found"
},
"manualLogin": "Manual login",
"mediaInfo": {
"audioChannels": {
"name": "Audio channels:"
},
"audioCodec": {
"name": "Audio codec:"
},
"bitrate": {
"name": "Bitrate:"
},
"container": {
"name": "Container:"
},
"name": "Media",
"subtitleCodec": {
"name": "Subtitle codec:"
},
"videoCodec": {
"name": "Video codec:"
}
},
"menu": "Menu",
"metadata": {
"source": "Source",
@@ -214,9 +241,52 @@
"shuffle": "Shuffle",
"shuffleAll": "Shuffle all"
},
"playbackData": "Playback data",
"playbackData": "Playback information",
"playbackInfo": {
"name": "Playback",
"playMethod": {
"direct": "Direct",
"name": "Method:",
"remux": "Remux",
"transcode": "Transcode"
},
"streamType": {
"dash": "DASH",
"hls": "HLS",
"name": "Stream type:",
"unknown": "Unknown",
"video": "Video"
},
"transcodingFramerate": {
"name": "Framerate:",
"value": "{value} fps"
},
"transcodingProgress": "Progress:",
"transcodingReason": {
"name": "Reason: | Reasons:"
}
},
"playbackSettings": "Playback settings",
"played": "Played",
"playerInfo": {
"corruptedFrames": {
"name": "Corrupted frames:"
},
"decodedFrames": {
"name": "Decoded frames:"
},
"droppedFrames": {
"name": "Dropped frames:"
},
"name": "Player",
"playbackResolution": {
"name": "Viewport resolution:"
},
"videoResolution": {
"name": "Video resolution:",
"value": "{width}✕{height}"
}
},
"present": "Present",
"producer": "Producer",
"quality": "Quality",
@@ -359,6 +429,7 @@
"speed": "Speed",
"status": "Status",
"studios": "Studios",
"subtitleCodecNotSupported": "The subtitle codec is not supported",
"subtitles": "Subtitles",
"syncPlayGroups": "SyncPlay groups",
"syncingInProgress": "Syncing in progress",
@@ -372,6 +443,9 @@
"switchToLightMode": "Switch to light mode"
},
"trailer": "Trailer",
"transcodingInfo": {
"name": "Transcoding"
},
"type": "Type",
"unableGetPublicUsers": "Unable to get users",
"unableGetRelated": "Unable to get related items",
@@ -394,6 +468,7 @@
},
"version": "Version",
"video": "Video",
"videoCodecNotSupported": "The video codec is not supported",
"videoTypes": "Video Types",
"viewDetails": "View details",
"vueClientVersion": "Client version",
+30
View File
@@ -140,6 +140,36 @@ export const getters: GetterTree<PlaybackManagerState, RootState> = {
return stream.Type === 'Subtitle';
});
}
},
getCurrentVideoTrack: (state) => {
if (
state.currentMediaSource !== null &&
state.currentVideoStreamIndex !== null
) {
return state.currentMediaSource.MediaStreams?.filter((stream) => {
return stream.Type === 'Video';
})[state.currentVideoStreamIndex];
}
},
getCurrentAudioTrack: (state) => {
if (
state.currentMediaSource !== null &&
state.currentAudioStreamIndex !== null
) {
return state.currentMediaSource.MediaStreams?.filter((stream) => {
return stream.Type === 'Audio';
})[state.currentAudioStreamIndex];
}
},
getCurrentSubtitleTrack: (state) => {
if (
state.currentMediaSource !== null &&
state.currentSubtitleStreamIndex !== null
) {
return state.currentMediaSource.MediaStreams?.filter((stream) => {
return stream.Type === 'Subtitle';
})[state.currentSubtitleStreamIndex];
}
}
};
+1
View File
@@ -44,6 +44,7 @@
"@nuxtjs/pwa": "^3.3.5",
"blurhash": "^1.1.3",
"body-scroll-lock": "^3.1.5",
"camel-case": "^4.1.2",
"compare-versions": "^3.6.0",
"cookie": "^0.4.1",
"date-fns": "^2.22.1",