Skip to content

Commit

Permalink
feat: add Media Info, download, and copy to context menu
Browse files Browse the repository at this point in the history
Split from the following PR: #1951
  • Loading branch information
noaione committed May 5, 2023
1 parent f25ac97 commit 7bb5e00
Show file tree
Hide file tree
Showing 17 changed files with 1,367 additions and 26 deletions.
72 changes: 51 additions & 21 deletions frontend/locales/en-US.json
Expand Up @@ -34,13 +34,17 @@
"byArtist": "By {artist}",
"cancel": "Cancel",
"castAndCrew": "Cast & crew",
"clipboardFail": "Failed to copy to clipboard",
"clipboardSuccess": "Copied to clipboard",
"close": "Close",
"collectionEmpty": "This collection is empty",
"collections": "Collections",
"communityRating": "Community rating",
"confirm": "Confirm",
"connect": "Connect",
"continueListening": "Continue listening",
"continueWatching": "Continue watching",
"copyStreamURL": "Copy Stream URL",
"criticRating": "Critic rating",
"customRating": "Custom rating",
"darkModeToggle": "Toggle dark mode",
Expand All @@ -61,6 +65,7 @@
"disabled": "Disabled",
"discNumber": "Disc {discNumber}",
"dislikes": "Dislikes",
"downloadItem": "Download | Download all",
"edit": "Edit",
"editMetadata": "Edit metadata",
"editPerson": "Edit person",
Expand Down Expand Up @@ -201,27 +206,52 @@
"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:"
}
},
"mediaInfo": "Media Info",
"mediaInfoAudioChannelLayout": "Layout:",
"mediaInfoAudioChannels": "Channels:",
"mediaInfoAudioSampleRate": "Sample rate:",
"mediaInfoFileContainer": "Container:",
"mediaInfoFilePath": "Path:",
"mediaInfoFileSize": "Size:",
"mediaInfoFileTitle": "Title:",
"mediaInfoGenericBitrate": "Bitrate:",
"mediaInfoGenericCodec": "Codec:",
"mediaInfoGenericCodecTag": "Codec tag:",
"mediaInfoGenericIsDefault": "Default:",
"mediaInfoGenericIsExternal": "External:",
"mediaInfoGenericIsForced": "Forced:",
"mediaInfoGenericLanguage": "Language:",
"mediaInfoGenericProfile": "Profile:",
"mediaInfoTitlesAudioCodec": "Audio | Audio {0}",
"mediaInfoTitlesEmbeddedImageCodec": "Image | Image {0}",
"mediaInfoTitlesSubtitleCodec": "Subtitle | Subtitle {0}",
"mediaInfoTitlesVideoCodec": "Video | Video {0}",
"mediaInfoVideoAspectRatio": "Aspect ratio:",
"mediaInfoVideoBitDepth": "Bit depth:",
"mediaInfoVideoBitrate": "Bitrate:",
"mediaInfoVideoColorPrimaries": "Color primaries:",
"mediaInfoVideoColorRange": "Color range:",
"mediaInfoVideoColorSpace": "Color space:",
"mediaInfoVideoColorTransfer": "Color transfer:",
"mediaInfoVideoDoViBlPresent": "DoVi BL present:",
"mediaInfoVideoDoViBlSignalCompatId": "DV bl signal compatibility ID:",
"mediaInfoVideoDoViElPresent": "DV el preset flag:",
"mediaInfoVideoDoViLevel": "DV level:",
"mediaInfoVideoDoViMajorVersion": "DV version major:",
"mediaInfoVideoDoViMinorVersion": "DV version minor:",
"mediaInfoVideoDoViProfile": "DV profile:",
"mediaInfoVideoDoViRpuPresent": "DV rpu preset flag:",
"mediaInfoVideoDoViTitle": "DV title:",
"mediaInfoVideoFrameRate": "Framerate:",
"mediaInfoVideoIsAnamorphic": "Anamorphic:",
"mediaInfoVideoIsAvc": "AVC:",
"mediaInfoVideoIsInterlaced": "Interlaced:",
"mediaInfoVideoLevel": "Level:",
"mediaInfoVideoPixelFormat": "Pixel format:",
"mediaInfoVideoRange": "Video range:",
"mediaInfoVideoRangeType": "Video range type:",
"mediaInfoVideoRefFrames": "Ref frames:",
"mediaInfoVideoResolution": "Resolution:",
"menu": "Menu",
"metadata": {
"source": "Source",
Expand Down
128 changes: 126 additions & 2 deletions frontend/src/components/Item/ItemMenu.vue
Expand Up @@ -45,6 +45,10 @@
v-if="identifyItemDialog && item.Id"
:item="menuProps.item"
@close="identifyItemDialog = false" />
<media-detail-dialog
v-if="mediaInfoDialog && item.Id"
:item="menuProps.item"
@close="mediaInfoDialog = false" />
</template>

<script lang="ts">
Expand All @@ -56,8 +60,12 @@ import IMdiPlaySpeed from 'virtual:icons/mdi/play-speed';
import IMdiArrowExpandUp from 'virtual:icons/mdi/arrow-expand-up';
import IMdiArrowExpandDown from 'virtual:icons/mdi/arrow-expand-down';
import IMdiCloudSearch from 'virtual:icons/mdi/cloud-search-outline';
import IMdiContentCopy from 'virtual:icons/mdi/content-copy';
import IMdiDelete from 'virtual:icons/mdi/delete';
import IMdiDisc from 'virtual:icons/mdi/disc';
import IMdiDownload from 'virtual:icons/mdi/download';
import IMdiDownloadMultiple from 'virtual:icons/mdi/download-multiple';
import IMdiInformation from 'virtual:icons/mdi/information';
import IMdiPlaylistMinus from 'virtual:icons/mdi/playlist-minus';
import IMdiPlaylistPlus from 'virtual:icons/mdi/playlist-plus';
import IMdiPencilOutline from 'virtual:icons/mdi/pencil-outline';
Expand All @@ -71,9 +79,18 @@ import {
canIdentify,
canInstantMix,
canRefreshMetadata,
canResume
canResume,
getItemDownloadObject,
getItemSeasonDownloadObjects,
getItemSeriesDownloadObjects
} from '@/utils/items';
import { playbackManagerStore, taskManagerStore } from '@/store';
import {
DownloadableFile,
canBrowserDownloadItem,
downloadFiles
} from '@/utils/file-download';
import { useClipboardWrite } from '@/composables/use-clipboard';
type MenuOption = {
title: string;
Expand Down Expand Up @@ -124,6 +141,7 @@ const positionY = ref<number | undefined>(undefined);
const metadataDialog = ref(false);
const refreshDialog = ref(false);
const identifyItemDialog = ref(false);
const mediaInfoDialog = ref(false);
const playbackManager = playbackManagerStore();
const taskManager = taskManagerStore();
const errorMessage = t('errors.anErrorHappened');
Expand Down Expand Up @@ -214,6 +232,13 @@ const instantMixAction = {
/**
* Item related actions
*/
const mediaInfoAction = {
title: t('mediaInfo'),
icon: IMdiInformation,
action: (): void => {
mediaInfoDialog.value = true;
}
};
const refreshAction = {
title: t('refreshMetadata'),
icon: IMdiRefresh,
Expand Down Expand Up @@ -268,6 +293,75 @@ const identifyItemAction = {
identifyItemDialog.value = true;
}
};
const sharedDownloadAction = async (): Promise<void> => {
if (menuProps.item.Id && menuProps.item.Type && menuProps.item.Path) {
let downloadURLs: DownloadableFile[] = [];
switch (menuProps.item.Type) {
case 'Season': {
downloadURLs = await getItemSeasonDownloadObjects(menuProps.item.Id);
break;
}
case 'Series': {
downloadURLs = await getItemSeriesDownloadObjects(menuProps.item.Id);
break;
}
default: {
const url = getItemDownloadObject(
menuProps.item.Id,
menuProps.item.Path
);
if (url) {
downloadURLs = [url];
}
break;
}
}
if (downloadURLs) {
try {
await downloadFiles(downloadURLs);
} catch (error) {
console.error(error);
useSnackbar(errorMessage, 'error');
}
} else {
console.error(
'Unable to get download URL for selected item/series/season'
);
useSnackbar(errorMessage, 'error');
}
}
};
const singleDownloadAction = {
title: t('downloadItem', 1),
icon: IMdiDownload,
action: sharedDownloadAction
};
const multiDownloadAction = {
title: t('downloadItem', 2),
icon: IMdiDownloadMultiple,
action: sharedDownloadAction
};
const copyStreamURLAction = {
title: t('copyStreamURL'),
icon: IMdiContentCopy,
action: async (): Promise<void> => {
if (menuProps.item.Id) {
const downloadHref = getItemDownloadObject(menuProps.item.Id);
if (downloadHref && downloadHref.url) {
await useClipboardWrite(downloadHref.url);
} else {
console.error('Unable to get stream URL for selected item');
useSnackbar(errorMessage, 'error');
}
}
}
};
/**
* == END OF ACTIONS ==
*/
Expand Down Expand Up @@ -332,12 +426,37 @@ function getPlaybackOptions(): MenuOption[] {
return playbackOptions;
}
/**
* Copy and download action for the current selected item
*/
function getCopyDownloadOptions(): MenuOption[] {
const copyDownloadActions: MenuOption[] = [];
if (menuProps.item.CanDownload) {
copyDownloadActions.push(copyStreamURLAction);
if (canBrowserDownloadItem(menuProps.item)) {
copyDownloadActions.push(singleDownloadAction);
if (['Season', 'Series'].includes(menuProps.item.Type || '')) {
copyDownloadActions.push(multiDownloadAction);
}
}
}
return copyDownloadActions;
}
/**
* Library options for libraries
*/
function getLibraryOptions(): MenuOption[] {
const libraryOptions: MenuOption[] = [];
if (menuProps.item.MediaSources) {
libraryOptions.push(mediaInfoAction);
}
if (canRefreshMetadata(menuProps.item)) {
libraryOptions.push(refreshAction);
}
Expand All @@ -358,7 +477,12 @@ function getLibraryOptions(): MenuOption[] {
}
const options = computed(() => {
return [getQueueOptions(), getPlaybackOptions(), getLibraryOptions()];
return [
getQueueOptions(),
getPlaybackOptions(),
getCopyDownloadOptions(),
getLibraryOptions()
];
});
/**
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/Item/MediaDetail/MediaDetailAttr.vue
@@ -0,0 +1,25 @@
<template>
<div class="d-flex flex-row">
<span class="mdinfo-label">{{ label }}</span>
<span class="mdinfo-value">{{ value ?? 'Unknown' }}</span>
</div>
</template>

<script setup lang="ts">
defineProps<{
label: string;
value: string | number | null;
}>();
</script>

<style lang="scss" scoped>
.mdinfo-label {
display: inline-block;
margin-right: 1rem;
font-weight: 600;
}
.mdinfo-value {
display: inline-block;
font-weight: 400;
}
</style>
31 changes: 31 additions & 0 deletions frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue
@@ -0,0 +1,31 @@
<template>
<media-detail-attr
v-if="stream.ColorSpace"
:label="t('mediaInfoVideoColorSpace')"
:value="stream.ColorSpace" />
<media-detail-attr
v-if="stream.ColorTransfer"
:label="t('mediaInfoVideoColorTransfer')"
:value="stream.ColorTransfer" />
<media-detail-attr
v-if="stream.ColorPrimaries"
:label="t('mediaInfoVideoColorPrimaries')"
:value="stream.ColorPrimaries" />
<media-detail-attr
v-if="stream.ColorRange"
:label="t('mediaInfoVideoColorRange')"
:value="stream.ColorRange" />
<media-detail-attr
v-if="stream.PixelFormat"
:label="t('mediaInfoVideoPixelFormat')"
:value="stream.PixelFormat" />
</template>

<script setup lang="ts">
import { MediaStream } from '@jellyfin/sdk/lib/generated-client';
import { useI18n } from 'vue-i18n';
defineProps<{ stream: MediaStream }>();
const { t } = useI18n();
</script>

0 comments on commit 7bb5e00

Please sign in to comment.