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 Apr 29, 2023
1 parent a1d212a commit 276936b
Show file tree
Hide file tree
Showing 17 changed files with 1,446 additions and 21 deletions.
71 changes: 61 additions & 10 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 @@ -116,8 +121,8 @@
},
"images": "Images",
"incorrectUsernameOrPassword": "Incorrect username or password",
"instantMixQueued": "Instant mix added to queue",
"instantMix": "Instant mix",
"instantMixQueued": "Instant mix added to queue",
"item": {
"artist": {
"albums": "Albums",
Expand Down Expand Up @@ -201,20 +206,66 @@
"name": "Audio channels:"
},
"audioCodec": {
"name": "Audio codec:"
},
"bitrate": {
"name": "Bitrate:"
},
"container": {
"name": "Container:"
"channels": "Channels:",
"layout": "Layout:",
"name": "Audio codec:",
"sampleRate": "Sample rate:",
"titles": "Audio | Audio {0}"
},
"embeddedImageCodec": {
"name": "Image codec:",
"titles": "Image | Image {0}"
},
"generic": {
"bitrate": "Bitrate:",
"codec": "Codec:",
"codecTag": "Codec tag:",
"container": "Container:",
"default": "Default:",
"external": "External:",
"forced": "Forced:",
"language": "Language:",
"path": "Path:",
"profile": "Profile:",
"size": "Size:",
"title": "Title:"
},
"name": "Media",
"subtitleCodec": {
"name": "Subtitle codec:"
"name": "Subtitle codec:",
"titles": "Subtitle | Subtitle {0}"
},
"title": "Media Info",
"videoCodec": {
"name": "Video codec:"
"DoVi": {
"blPresent": "DV bl preset flag:",
"blSignalCompatibilityId": "DV bl signal compatibility ID:",
"elPresent": "DV el preset flag:",
"level": "DV level:",
"majorVersion": "DV version major:",
"minorVersion": "DV version minor:",
"profile": "DV profile:",
"rpuPresent": "DV rpu preset flag:",
"title": "DV title:"
},
"aspectRatio": "Aspect ratio:",
"bitdepth": "Bit depth:",
"colorPrimaries": "Color primaries:",
"colorRange": "Color range:",
"colorSpace": "Color space:",
"colorTransfer": "Color transfer:",
"frameRate": "Framerate:",
"isAnamorphic": "Anamorphic:",
"isAvc": "AVC:",
"isInterlaced": "Interlaced:",
"level": "Level:",
"name": "Video codec:",
"pixelFormat": "Pixel format:",
"refFrames": "Ref frames:",
"resolution": "Resolution:",
"titles": "Video | Video {0}",
"videoRange": "Video range:",
"videoRangeType": "Video range type:"
}
},
"menu": "Menu",
Expand Down
141 changes: 139 additions & 2 deletions frontend/src/components/Item/ItemMenu.vue
Expand Up @@ -41,6 +41,10 @@
v-if="refreshDialog && item.Id"
:item="menuProps.item"
@close="refreshDialog = false" />
<media-detail-dialog
v-if="mediaInfoDialog && item.Id"
:item="menuProps.item"
@close="mediaInfoDialog = false" />
</template>

<script lang="ts">
Expand All @@ -51,8 +55,12 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
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 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 @@ -62,8 +70,21 @@ import IMdiRefresh from 'virtual:icons/mdi/refresh';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { useRoute, useRouter } from 'vue-router';
import { useRemote, useSnackbar, useConfirmDialog } from '@/composables';
import { canInstantMix, canRefreshMetadata, canResume } from '@/utils/items';
import {
canInstantMix,
canRefreshMetadata,
canResume,
getItemDownloadObject,
getItemSeasonDownloadObjects,
getItemSeriesDownloadObjects
} from '@/utils/items';
import { playbackManagerStore, taskManagerStore } from '@/store';
import {
DownloadableFile,
canBrowserDownloadItem,
downloadFiles
} from '@/utils/file-download';
import { writeToClipboard } from '@/utils/clipboard';
type MenuOption = {
title: string;
Expand Down Expand Up @@ -113,6 +134,7 @@ const positionX = ref<number | undefined>(undefined);
const positionY = ref<number | undefined>(undefined);
const metadataDialog = ref(false);
const refreshDialog = ref(false);
const mediaInfoDialog = ref(false);
const playbackManager = playbackManagerStore();
const taskManager = taskManagerStore();
const errorMessage = t('errors.anErrorHappened');
Expand Down Expand Up @@ -203,6 +225,13 @@ const instantMixAction = {
/**
* Item related actions
*/
const mediaInfoAction = {
title: t('mediaInfo.title'),
icon: IMdiInformation,
action: (): void => {
mediaInfoDialog.value = true;
}
};
const refreshAction = {
title: t('refreshMetadata'),
icon: IMdiRefresh,
Expand Down Expand Up @@ -250,6 +279,75 @@ const deleteItemAction = {
);
}
};
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 copy(downloadHref.url);
} else {
console.error('Unable to get stream URL for selected item');
useSnackbar(errorMessage, 'error');
}
}
}
};
/**
* == END OF ACTIONS ==
*/
Expand Down Expand Up @@ -314,12 +412,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 @@ -336,7 +459,12 @@ function getLibraryOptions(): MenuOption[] {
}
const options = computed(() => {
return [getQueueOptions(), getPlaybackOptions(), getLibraryOptions()];
return [
getQueueOptions(),
getPlaybackOptions(),
getCopyDownloadOptions(),
getLibraryOptions()
];
});
/**
Expand All @@ -358,6 +486,15 @@ function onActivatorClick(): void {
positionY.value = undefined;
}
const copy = async (text: string): Promise<void> => {
try {
await writeToClipboard(text);
useSnackbar(t('clipboardSuccess'), 'success');
} catch {
useSnackbar(t('clipboardFail'), 'error');
}
};
onMounted(() => {
const parentHtml = parent?.subTree.el;
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('mediaInfo.videoCodec.colorSpace')"
:value="stream.ColorSpace" />
<media-detail-attr
v-if="stream.ColorTransfer"
:label="t('mediaInfo.videoCodec.colorTransfer')"
:value="stream.ColorTransfer" />
<media-detail-attr
v-if="stream.ColorPrimaries"
:label="t('mediaInfo.videoCodec.colorPrimaries')"
:value="stream.ColorPrimaries" />
<media-detail-attr
v-if="stream.ColorRange"
:label="t('mediaInfo.videoCodec.colorRange')"
:value="stream.ColorRange" />
<media-detail-attr
v-if="stream.PixelFormat"
:label="t('mediaInfo.videoCodec.pixelFormat')"
: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 276936b

Please sign in to comment.