diff --git a/frontend/locales/en-US.json b/frontend/locales/en-US.json index b841f40c0e7..228cb09b9a3 100644 --- a/frontend/locales/en-US.json +++ b/frontend/locales/en-US.json @@ -34,6 +34,9 @@ "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", @@ -41,6 +44,7 @@ "connect": "Connect", "continueListening": "Continue listening", "continueWatching": "Continue watching", + "copyStreamURL": "Copy Stream URL", "criticRating": "Critic rating", "customRating": "Custom rating", "darkModeToggle": "Toggle dark mode", @@ -61,6 +65,7 @@ "disabled": "Disabled", "discNumber": "Disc {discNumber}", "dislikes": "Dislikes", + "downloadItem": "Download | Download all", "edit": "Edit", "editMetadata": "Edit metadata", "editPerson": "Edit person", @@ -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", @@ -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", diff --git a/frontend/src/components/Item/ItemMenu.vue b/frontend/src/components/Item/ItemMenu.vue index 0338b0d1e10..bbe05363e86 100644 --- a/frontend/src/components/Item/ItemMenu.vue +++ b/frontend/src/components/Item/ItemMenu.vue @@ -41,6 +41,10 @@ v-if="refreshDialog && item.Id" :item="menuProps.item" @close="refreshDialog = false" /> + + + diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue b/frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue new file mode 100644 index 00000000000..c2678e04ec1 --- /dev/null +++ b/frontend/src/components/Item/MediaDetail/MediaDetailColorSpace.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailContent.vue b/frontend/src/components/Item/MediaDetail/MediaDetailContent.vue new file mode 100644 index 00000000000..066315279f4 --- /dev/null +++ b/frontend/src/components/Item/MediaDetail/MediaDetailContent.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailCopy.vue b/frontend/src/components/Item/MediaDetail/MediaDetailCopy.vue new file mode 100644 index 00000000000..0b7b6d855e0 --- /dev/null +++ b/frontend/src/components/Item/MediaDetail/MediaDetailCopy.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailDialog.vue b/frontend/src/components/Item/MediaDetail/MediaDetailDialog.vue new file mode 100644 index 00000000000..5748c27aa7c --- /dev/null +++ b/frontend/src/components/Item/MediaDetail/MediaDetailDialog.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailExtras.vue b/frontend/src/components/Item/MediaDetail/MediaDetailExtras.vue new file mode 100644 index 00000000000..db4737a10a1 --- /dev/null +++ b/frontend/src/components/Item/MediaDetail/MediaDetailExtras.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/components/Item/MediaDetail/MediaDetailGeneric.vue b/frontend/src/components/Item/MediaDetail/MediaDetailGeneric.vue new file mode 100644 index 00000000000..4993a8db719 --- /dev/null +++ b/frontend/src/components/Item/MediaDetail/MediaDetailGeneric.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/Playback/TrackList.vue b/frontend/src/components/Playback/TrackList.vue index f7fb8f17cfc..b6aee3d034e 100644 --- a/frontend/src/components/Playback/TrackList.vue +++ b/frontend/src/components/Playback/TrackList.vue @@ -69,7 +69,7 @@ - + @@ -113,7 +113,11 @@ async function fetch(): Promise { parentId: props.item.Id, sortBy: ['SortName'], sortOrder: [SortOrder.Ascending], - fields: [ItemFields.CanDelete] + fields: [ + ItemFields.MediaSources, + ItemFields.CanDelete, + ItemFields.CanDownload + ] }) ).data.Items; } diff --git a/frontend/src/store/userLibraries.ts b/frontend/src/store/userLibraries.ts index 0a9cd4ac54c..d954b0ba567 100644 --- a/frontend/src/store/userLibraries.ts +++ b/frontend/src/store/userLibraries.ts @@ -148,7 +148,12 @@ class UserLibrariesStore { await remote.sdk.newUserApi(getItemsApi).getResumeItems({ userId: remote.auth.currentUserId || '', limit: 24, - fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSources, + ItemFields.CanDelete, + ItemFields.CanDownload + ], imageTypeLimit: 1, enableImageTypes: [ ImageType.Primary, @@ -174,7 +179,12 @@ class UserLibrariesStore { await remote.sdk.newUserApi(getItemsApi).getResumeItems({ userId: remote.auth.currentUserId || '', limit: 24, - fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSources, + ItemFields.CanDelete, + ItemFields.CanDownload + ], imageTypeLimit: 1, enableImageTypes: [ ImageType.Primary, @@ -200,7 +210,12 @@ class UserLibrariesStore { await remote.sdk.newUserApi(getTvShowsApi).getNextUp({ userId: remote.auth.currentUserId, limit: 24, - fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSources, + ItemFields.CanDelete, + ItemFields.CanDownload + ], imageTypeLimit: 1, enableImageTypes: [ ImageType.Primary, @@ -228,7 +243,12 @@ class UserLibrariesStore { await remote.sdk.newUserApi(getUserLibraryApi).getLatestMedia({ userId: remote.auth.currentUserId || '', limit: 24, - fields: [ItemFields.PrimaryImageAspectRatio, ItemFields.CanDelete], + fields: [ + ItemFields.PrimaryImageAspectRatio, + ItemFields.MediaSources, + ItemFields.CanDelete, + ItemFields.CanDownload + ], imageTypeLimit: 1, enableImageTypes: [ ImageType.Primary, @@ -256,7 +276,9 @@ class UserLibrariesStore { fields: [ ItemFields.Overview, ItemFields.PrimaryImageAspectRatio, - ItemFields.CanDelete + ItemFields.MediaSources, + ItemFields.CanDelete, + ItemFields.CanDownload ], enableImageTypes: [ImageType.Backdrop, ImageType.Logo], imageTypeLimit: 1 diff --git a/frontend/src/utils/browser-detection.ts b/frontend/src/utils/browser-detection.ts index 0fa8d01e535..3df68d1d3b8 100644 --- a/frontend/src/utils/browser-detection.ts +++ b/frontend/src/utils/browser-detection.ts @@ -28,10 +28,16 @@ export function supportsMediaSource(): boolean { * @private * @static * @param key - Key for which to perform a check. + * @param caseSensitive - Whether the check should be case sensitive. * @returns Determines if user agent of navigator contains a key */ -function userAgentContains(key: string): boolean { - const userAgent = navigator.userAgent || ''; +function userAgentContains(key: string, caseSensitive = true): boolean { + let userAgent = navigator.userAgent || ''; + + if (!caseSensitive) { + key = key.toLowerCase(); + userAgent = userAgent.toLowerCase(); + } return userAgent.includes(key); } @@ -57,6 +63,22 @@ export function isEdge(): boolean { return userAgentContains('Edg/') || userAgentContains('Edge/'); } +/** + * Check if the current platform is Microsoft Edge UWP. + * + * @static + * @returns Determines if browser is Microsoft Edge UWP. + */ +export function isEdgeUWP(): boolean { + if (!isEdge()) { + return false; + } + + return ( + userAgentContains('msapphost', false) || userAgentContains('webview', false) + ); +} + /** * Check if the current platform is Chromium based. * diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 00000000000..ca687ed0de5 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,8 @@ +/** + * Write text to the clipboard. + * + * @param text - The text to write to the clipboard. + */ +export async function writeToClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} diff --git a/frontend/src/utils/file-download.ts b/frontend/src/utils/file-download.ts new file mode 100644 index 00000000000..ea8fe3b52c4 --- /dev/null +++ b/frontend/src/utils/file-download.ts @@ -0,0 +1,88 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; +import { isEdgeUWP, isFirefox, isPs4, isTv, isXbox } from './browser-detection'; + +export interface DownloadableFile { + // The file URL + url: string; + // The filename, including the file extension + fileName: string; +} + +/** + * Check if the url is on the same domain as the current page. + * + * @param url - The url to check. + */ +function sameDomain(url: string): boolean { + const a = document.createElement('a'); + + a.href = url; + + return ( + window.location.hostname === a.hostname && + window.location.protocol === a.protocol + ); +} + +/** + * Use html tag to download a file. + * + * @param file - An object with `url` and `fileName` properties. + */ +function downloadBrowser(file: DownloadableFile): void { + const a = document.createElement('a'); + + a.download = file.fileName; + a.href = file.url; + // firefox doesn't support `a.click()`... + a.dispatchEvent(new MouseEvent('click')); +} + +/** + * Check if the browser are able to download the item. + * + * @param item - The item to check. + */ +export function canBrowserDownloadItem(item: BaseItemDto): boolean { + return ( + !isEdgeUWP() && !isTv() && !isXbox() && !isPs4() && item.Type !== 'Book' + ); +} + +/** + * Download multiple files. + * + * @param filesToDownload - An array of objects with `url` and `fileName` properties. + */ +export async function downloadFiles( + filesToDownload: DownloadableFile | DownloadableFile[] +): Promise { + if (!filesToDownload) { + throw new Error('`filesToDownload` required'); + } + + const files = Array.isArray(filesToDownload) + ? filesToDownload + : [filesToDownload]; + + if (files.length === 0) { + throw new Error( + '`filesToDownload` must be an array with at least one item' + ); + } + + if (document.createElement('a').download === undefined) { + throw new Error('Browser does not support downloading files'); + } + + let delay = 0; + + for (const file of files) { + if (isFirefox() && !sameDomain(file.url)) { + // the download init has to be sequential for firefox if the urls are not on the same domain + setTimeout(downloadBrowser.bind(undefined, file), 100 * ++delay); + } else { + downloadBrowser(file); + } + } +} diff --git a/frontend/src/utils/items.ts b/frontend/src/utils/items.ts index ac54afedb40..eb44d515135 100644 --- a/frontend/src/utils/items.ts +++ b/frontend/src/utils/items.ts @@ -5,6 +5,7 @@ import { BaseItemDto, BaseItemKind, BaseItemPerson, + ItemFields, MediaStream } from '@jellyfin/sdk/lib/generated-client'; import { useRouter } from 'vue-router'; @@ -26,6 +27,9 @@ import IMdiBookMusic from 'virtual:icons/mdi/book-music'; import IMdiFolderMultiple from 'virtual:icons/mdi/folder-multiple'; import IMdiFilmstrip from 'virtual:icons/mdi/filmstrip'; import IMdiAlbum from 'virtual:icons/mdi/album'; +import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; +import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'; +import { DownloadableFile } from './file-download'; import { useRemote } from '@/composables'; /** @@ -431,3 +435,120 @@ export function getMediaStreams( ): MediaStream[] { return mediaStreams.filter((mediaStream) => mediaStream.Type === streamType); } + +/** + * Create an item download object that contains the URL and filename. + * + * @param itemId - The item ID. + * @param itemPath - The item path. + * @returns - A download object. + */ +export function getItemDownloadObject( + itemId: string, + itemPath?: string +): DownloadableFile | undefined { + const remote = useRemote(); + + const serverAddress = remote.sdk.api?.basePath; + const userToken = remote.sdk.api?.accessToken; + + if (!serverAddress || !userToken) { + return undefined; + } + + const fileName = itemPath?.includes('\\') + ? itemPath?.split('\\').pop() + : itemPath?.split('/').pop(); + + return { + url: `${serverAddress}/Items/${itemId}/Download?api_key=${userToken}`, + fileName: fileName || '' + }; +} + +/** + * Get multiple download object for seasons. + * + * @param seasonId - The season ID. + * @returns - An array of download objects. + */ +export async function getItemSeasonDownloadObjects( + seasonId: string +): Promise { + const remote = useRemote(); + + if (remote.sdk.api === undefined) { + return []; + } + + const episodes = ( + await remote.sdk.newUserApi(getItemsApi).getItems({ + userId: remote.auth.currentUserId, + parentId: seasonId, + fields: [ItemFields.Overview, ItemFields.CanDownload, ItemFields.Path] + }) + ).data; + + return ( + episodes.Items?.map((r) => { + if (r.Id && r.Path) { + return getItemDownloadObject(r.Id, r.Path); + } + }).filter( + (r): r is DownloadableFile => + r !== undefined && r.url.length > 0 && r.fileName.length > 0 + ) ?? [] + ); +} + +/** + * Get download object for a series. + * This will fetch every season for all the episodes. + * + * @param seriesId - The series ID. + * @returns - An array of download objects. + */ +export async function getItemSeriesDownloadObjects( + seriesId: string +): Promise { + const remote = useRemote(); + + let mergedStreamURLs: DownloadableFile[] = []; + + if (remote.sdk.api === undefined) { + return []; + } + + const seasons = ( + await remote.sdk.newUserApi(getTvShowsApi).getSeasons({ + userId: remote.auth.currentUserId, + seriesId: seriesId + }) + ).data; + + for (const season of seasons.Items || []) { + const seasonURLs = await getItemSeasonDownloadObjects(season.Id || ''); + + mergedStreamURLs = [...mergedStreamURLs, ...seasonURLs]; + } + + return mergedStreamURLs; +} + +/** + * Format a number of bytes into a human readable string + * + * @param size - The number of bytes to format + * @returns - A human readable string + */ +export function formatFileSize(size: number): string { + if (size === 0) { + return '0 B'; + } + + const i = Math.floor(Math.log(size) / Math.log(1024)); + + return `${(size / Math.pow(1024, i)).toFixed(2)} ${ + ['B', 'kiB', 'MiB', 'GiB', 'TiB', 'PiB'][i] + }`; +} diff --git a/frontend/src/utils/mediainfo.ts b/frontend/src/utils/mediainfo.ts new file mode 100644 index 00000000000..550d48aa951 --- /dev/null +++ b/frontend/src/utils/mediainfo.ts @@ -0,0 +1,354 @@ +import { + MediaSourceInfo, + MediaStream +} from '@jellyfin/sdk/lib/generated-client'; +import { isBoolean, isNil } from 'lodash-es'; +import { formatFileSize } from './items'; +import { usei18n } from '@/composables'; + +const { t } = usei18n(); +const profileT = t('mediaInfo.generic.profile'); + +type MediaItem = string | number | boolean; +type NoneType = null | undefined; + +/** + * Format a boolean value into a Yes/No string. + * @param value - The boolean value to format. + */ +export function formatYesOrNo(value: boolean | NoneType): string { + return value ? 'Yes' : 'No'; +} + +/** + * Check if a value is a valid media item. + */ +function checkValidValue(value: MediaItem | NoneType): value is MediaItem { + if (isNil(value)) { + return false; + } + + if (typeof value === 'string') { + return value.trim().length > 0; + } + + return true; +} + +/** + * Format a media attribute into a mediainfo text format. + * @param key - The key of the attribute. + * @param value - The value of the attribute. + * @param suffix - An optional suffix to append to the attribute. + */ +function formatMediaAttr( + key: string, + value: MediaItem | NoneType, + suffix?: string +): string { + return checkValidValue(value) + ? `${key} ${value} ${suffix ?? ''}`.trimEnd() + '\n' + : ''; +} + +/** + * Create generic information about the media stream. + * @param stream - The media stream to create information for. + */ +function createGenericInfo(stream: MediaStream): string { + let mediaInfo = ''; + + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.title'), + stream.DisplayTitle + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.language'), + stream.Language + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.codec'), + stream.Codec?.toUpperCase() + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.codecTag'), + stream.CodecTag + ); + + return mediaInfo; +} + +/** + * Create generic information about the Default/Forced/External status of a stream. + * @param stream - The media stream to create information for. + */ +function createExtraInformation(stream: MediaStream): string { + let mediaInfo = ''; + + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.default'), + formatYesOrNo(stream.IsDefault) + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.forced'), + formatYesOrNo(stream.IsForced) + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.external'), + formatYesOrNo(stream.IsExternal) + ); + + return mediaInfo; +} + +/** + * Create information about Dolby Vision if exist. + * @param stream - The media stream to create information for. + */ +function createVideoDoViInformation(stream: MediaStream): string { + let mediaInfo = ''; + + if (typeof stream.VideoDoViTitle !== 'string') { + return mediaInfo; + } + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.title'), + stream.VideoDoViTitle + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.majorVersion'), + stream.DvVersionMajor + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.minorVersion'), + stream.DvVersionMinor + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.profile'), + stream.DvProfile + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.level'), + stream.DvLevel + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.rpuPresent'), + stream.RpuPresentFlag + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.elPresent'), + stream.ElPresentFlag + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.blPresent'), + stream.BlPresentFlag + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.DoVi.blSignalCompatibilityId'), + stream.DvBlSignalCompatibilityId + ); + + return mediaInfo; +} + +/** + * Create information about the color space used in the video stream. + * @param stream - The media stream to create information for. + */ +function createVideoColorInformation(stream: MediaStream): string { + let mediaInfo = ''; + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.colorSpace'), + stream.ColorSpace + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.colorTransfer'), + stream.ColorTransfer + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.colorPrimaries'), + stream.ColorPrimaries + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.colorRange'), + stream.ColorRange + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.pixelFormat'), + stream.PixelFormat + ); + + return mediaInfo; +} + +/** + * Format video media info into a mediainfo text format + * @param stream - The media stream to create information for. + */ +export function createVideoInformation(stream: MediaStream): string { + let mediaInfo = createGenericInfo(stream); + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.isAvc'), + formatYesOrNo(stream.IsAVC) + ); + mediaInfo += formatMediaAttr(profileT, stream.Profile); + mediaInfo += formatMediaAttr(t('mediaInfo.videoCodec.level'), stream.Level); + + if (stream.Width || stream.Height) { + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.resolution'), + `${stream.Width}x${stream.Height}` + ); + } + + if (stream.AspectRatio) { + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.aspectRatio'), + stream.AspectRatio + ); + } + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.isAnamorphic'), + isBoolean(stream.IsAnamorphic) + ? formatYesOrNo(stream.IsAnamorphic) + : undefined + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.isInterlaced'), + formatYesOrNo(stream.IsInterlaced) + ); + + if (stream.AverageFrameRate || stream.RealFrameRate) { + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.frameRate'), + (stream.AverageFrameRate || stream.RealFrameRate)?.toFixed(3), + 'fps' + ); + } + + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.bitrate'), + stream.BitRate ? (stream.BitRate / 1000).toFixed(2) : '', + 'kbps' + ); + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.bitdepth'), + stream.BitDepth, + 'bits' + ); + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.videoRange'), + stream.VideoRange + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.videoRangeType'), + stream.VideoRangeType + ); + + mediaInfo += createVideoDoViInformation(stream); + mediaInfo += createVideoColorInformation(stream); + mediaInfo += formatMediaAttr('NAL', stream.NalLengthSize); + + return mediaInfo; +} + +/** + * Format audio media info into a mediainfo text format + * @param stream - The media stream to create information for. + */ +export function createAudioInformation(stream: MediaStream): string { + let mediaInfo = createGenericInfo(stream); + + mediaInfo += formatMediaAttr(profileT, stream.Profile); + mediaInfo += formatMediaAttr( + t('mediaInfo.audioCodec.layout'), + stream.ChannelLayout + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.audioCodec.channels'), + stream.Channels, + 'ch' + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.bitrate'), + stream.BitRate ? (stream.BitRate / 1000).toFixed(2) : '', + 'kbps' + ); + mediaInfo += formatMediaAttr( + t('mediaInfo.audioCodec.sampleRate'), + stream.SampleRate, + 'Hz' + ); + + mediaInfo += createExtraInformation(stream); + + return mediaInfo; +} + +/** + * Format subtitle media info into a mediainfo text format + * @param stream - The media stream to create information for. + */ +export function createSubsInformation(stream: MediaStream): string { + let mediaInfo = createGenericInfo(stream); + + mediaInfo += createExtraInformation(stream); + + return mediaInfo; +} + +/** + * Format embbedded media info into a mediainfo text format + * @param stream - The media stream to create information for. + */ +export function createEmbeddedInformation(stream: MediaStream): string { + let mediaInfo = createGenericInfo(stream); + + mediaInfo += formatMediaAttr(profileT, stream.Profile); + + if (stream.Width || stream.Height) { + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.resolution'), + `${stream.Width}x${stream.Height}` + ); + } + + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.bitdepth'), + stream.BitDepth, + 'bits' + ); + mediaInfo += createVideoColorInformation(stream); + mediaInfo += formatMediaAttr( + t('mediaInfo.videoCodec.refFrames'), + stream.RefFrames + ); + + return mediaInfo; +} + +/** + * Format container media info into a mediainfo text format + * @param media - The media source to create information for. + */ +export function createContainerInformation(media: MediaSourceInfo): string { + let mediaInfo = ''; + + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.container'), + media.Container + ); + mediaInfo += formatMediaAttr(t('mediaInfo.generic.path'), media.Path); + mediaInfo += formatMediaAttr( + t('mediaInfo.generic.size'), + media.Size ? formatFileSize(media.Size) : '' + ); + + return mediaInfo; +} diff --git a/frontend/types/global/components.d.ts b/frontend/types/global/components.d.ts index 2fb984d40b1..de5dfaf9103 100644 --- a/frontend/types/global/components.d.ts +++ b/frontend/types/global/components.d.ts @@ -50,6 +50,7 @@ declare module '@vue/runtime-core' { IMdiClosedCaptionOutline: typeof import('~icons/mdi/closed-caption-outline')['default'] IMdiCloudDownload: typeof import('~icons/mdi/cloud-download')['default'] IMdiCog: typeof import('~icons/mdi/cog')['default'] + IMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] IMdiContentSave: typeof import('~icons/mdi/content-save')['default'] IMdiDelete: typeof import('~icons/mdi/delete')['default'] IMdiDisc: typeof import('~icons/mdi/disc')['default'] @@ -95,6 +96,13 @@ declare module '@vue/runtime-core' { LocaleSwitcher: typeof import('./../../src/components/System/LocaleSwitcher.vue')['default'] LoginForm: typeof import('./../../src/components/Forms/LoginForm.vue')['default'] MarkPlayedButton: typeof import('./../../src/components/Buttons/MarkPlayedButton.vue')['default'] + MediaDetailAttr: typeof import('./../../src/components/Item/MediaDetail/MediaDetailAttr.vue')['default'] + MediaDetailColorSpace: typeof import('./../../src/components/Item/MediaDetail/MediaDetailColorSpace.vue')['default'] + MediaDetailContent: typeof import('./../../src/components/Item/MediaDetail/MediaDetailContent.vue')['default'] + MediaDetailCopy: typeof import('./../../src/components/Item/MediaDetail/MediaDetailCopy.vue')['default'] + MediaDetailDialog: typeof import('./../../src/components/Item/MediaDetail/MediaDetailDialog.vue')['default'] + MediaDetailExtras: typeof import('./../../src/components/Item/MediaDetail/MediaDetailExtras.vue')['default'] + MediaDetailGeneric: typeof import('./../../src/components/Item/MediaDetail/MediaDetailGeneric.vue')['default'] MediaInfo: typeof import('./../../src/components/Item/MediaInfo.vue')['default'] MediaStreamSelector: typeof import('./../../src/components/Item/MediaStreamSelector.vue')['default'] MetadataEditor: typeof import('./../../src/components/Item/Metadata/MetadataEditor.vue')['default']