[#433] Added music UI to Jellyfin.

This commit is contained in:
Fabio Manganiello 2024-10-14 21:48:07 +02:00
parent 4f81a73fb9
commit 86559a623a
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
9 changed files with 861 additions and 169 deletions

View file

@ -4,7 +4,7 @@
@click.prevent="searchAlbum" @click.prevent="searchAlbum"
v-if="status?.state !== 'stop'"> v-if="status?.state !== 'stop'">
<div class="remote-image-container" v-if="trackImage"> <div class="remote-image-container" v-if="trackImage">
<img class="image" :src="trackImage" :alt="track.title"> <img class="image" :src="trackImage" :alt="trackTitle">
</div> </div>
<div class="icon-container" v-else> <div class="icon-container" v-else>
@ -59,18 +59,18 @@
<div class="track-container col-s-9 col-m-9 col-l-3"> <div class="track-container col-s-9 col-m-9 col-l-3">
<div class="track-info" @click="$emit('info', track)" v-if="track && status?.state !== 'stop'"> <div class="track-info" @click="$emit('info', track)" v-if="track && status?.state !== 'stop'">
<div class="img-container" v-if="trackImage"> <div class="img-container" v-if="trackImage">
<img class="image from desktop" :src="trackImage" :alt="track.title"> <img class="image from desktop" :src="trackImage" :alt="trackTitle">
</div> </div>
<div class="title-container"> <div class="title-container">
<div class="title" v-if="status.state === 'play' || status.state === 'pause'"> <div class="title" v-if="status.state === 'play' || status.state === 'pause'">
<a :href="$route.fullPath" v-text="track.title?.length ? track.title : '[No Title]'" <a :href="$route.fullPath" v-text="trackTitle"
@click.prevent="searchAlbum" v-if="track.album"></a> @click.prevent="searchAlbum" v-if="track.album"></a>
<a v-text="track.title?.length ? track.title : '[No Title]'" v-else-if="track.url"></a> <a v-text="trackTitle" v-else-if="track.url"></a>
<span v-text="track.title?.length ? track.title : '[No Title]' " v-else></span> <span v-text="trackTitle" v-else></span>
</div> </div>
<div class="artist" v-if="track.artist?.length && (status.state === 'play' || status.state === 'pause')"> <div class="artist" v-if="trackArtistName?.length && (status.state === 'play' || status.state === 'pause')">
<a v-text="track.artist" @click.prevent="searchArtist"></a> <a v-text="trackArtistName" @click.prevent="searchArtist"></a>
</div> </div>
</div> </div>
</div> </div>
@ -206,12 +206,27 @@ export default {
return null return null
}, },
trackArtistId() {
return typeof this.track?.artist === 'object' ? this.track.artist.id : null
},
trackArtistName() {
if (typeof this.track?.artist === 'string')
return this.track.artist
return this.track?.artist?.name || this.track?.artist?.title
},
trackImage() { trackImage() {
if (this.track?.images?.length) if (this.track?.images?.length)
return this.track.images[0].url return this.track.images[0].url
return this.track?.image || this.image return this.track?.image || this.image
}, },
trackTitle() {
return this.track?.title || this.track?.name || '[No Title]'
},
}, },
methods: { methods: {
@ -235,11 +250,11 @@ export default {
}, },
searchArtist() { searchArtist() {
if (!this.track?.artist) if (!this.trackArtistName?.length)
return return
const args = { const args = {
artist: this.track.artist, artist: this.trackArtistName,
} }
if (this.track.artist_uri) if (this.track.artist_uri)

View file

@ -95,6 +95,10 @@
/> />
</div> </div>
</div> </div>
<div class="media-loading-indicator" v-if="opening">
<Loading />
</div>
</main> </main>
</MediaView> </MediaView>
@ -149,6 +153,7 @@ import Utils from "@/Utils";
import Browser from "@/components/panels/Media/Browser"; import Browser from "@/components/panels/Media/Browser";
import Header from "@/components/panels/Media/Header"; import Header from "@/components/panels/Media/Header";
import Info from "@/components/panels/Media/Info"; import Info from "@/components/panels/Media/Info";
import Loading from "@/components/Loading";
import MediaDownloads from "@/components/panels/Media/Downloads"; import MediaDownloads from "@/components/panels/Media/Downloads";
import MediaUtils from "@/components/Media/Utils"; import MediaUtils from "@/components/Media/Utils";
import MediaView from "@/components/Media/View"; import MediaView from "@/components/Media/View";
@ -166,6 +171,7 @@ export default {
Browser, Browser,
Header, Header,
Info, Info,
Loading,
MediaDownloads, MediaDownloads,
MediaView, MediaView,
Modal, Modal,
@ -205,6 +211,7 @@ export default {
forceShowNav: false, forceShowNav: false,
infoTrack: null, infoTrack: null,
loading: false, loading: false,
opening: false,
prevSelectedView: null, prevSelectedView: null,
results: [], results: [],
selectedPlayer: null, selectedPlayer: null,
@ -324,7 +331,7 @@ export default {
return return
} }
this.loading = true this.opening = true
try { try {
if (!this.selectedPlayer.component.supports(item)) if (!this.selectedPlayer.component.supports(item))
@ -334,9 +341,9 @@ export default {
item, this.selectedSubtitles, this.selectedPlayer, opts item, this.selectedSubtitles, this.selectedPlayer, opts
) )
await this.refresh() await this.refresh(item)
} finally { } finally {
this.loading = false this.opening = false
} }
}, },
@ -383,15 +390,36 @@ export default {
await this.download(item, {onlyAudio: true}) await this.download(item, {onlyAudio: true})
}, },
async refresh() { async refresh(item) {
this.selectedPlayer.status = await this.selectedPlayer.component.status(this.selectedPlayer) let newStatus = {
...(await this.selectedPlayer.component.status(this.selectedPlayer)),
...(item || {}),
}
this.setStatus(newStatus)
},
setStatus(status) {
const curStatus = this.selectedPlayer?.status || {}
let newStatus = {}
if (curStatus.resource === status.resource) {
newStatus = {
...curStatus,
...status,
}
} else {
newStatus = status
}
this.selectedPlayer.status = newStatus
}, },
onStatusUpdate(status) { onStatusUpdate(status) {
if (!this.selectedPlayer) if (!this.selectedPlayer)
return return
this.selectedPlayer.status = status this.setStatus(status)
}, },
onPlayUrlModalOpen() { onPlayUrlModalOpen() {
@ -558,17 +586,7 @@ export default {
async playUrl(url) { async playUrl(url) {
this.urlPlay = url this.urlPlay = url
this.loading = true await this.play({ url: url })
try {
await this.play({
url: url,
})
this.$refs.playUrlModal.close()
} finally {
this.loading = false
}
}, },
async refreshDownloads() { async refreshDownloads() {
@ -788,6 +806,7 @@ export default {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
position: relative;
.view-container { .view-container {
display: flex; display: flex;
@ -806,6 +825,24 @@ export default {
height: calc(100% - #{$media-header-height} - #{$filter-header-height} - #{$media-ctrl-panel-height}); height: calc(100% - #{$media-header-height} - #{$filter-header-height} - #{$media-ctrl-panel-height});
} }
} }
:deep(.media-loading-indicator) {
position: absolute;
top: 0;
left: 0;
width: 5em;
height: 5em;
border-radius: 50%;
background: rgba(0, 0, 0, 0);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
.loading {
border-radius: 50%;
}
}
} }
} }

View file

@ -1,5 +1,7 @@
<template> <template>
<div class="media-info"> <div class="media-info">
<Loading v-if="loading" />
<div class="row header"> <div class="row header">
<div class="item-container"> <div class="item-container">
<Item :item="item" <Item :item="item"
@ -25,91 +27,113 @@
</div> </div>
</div> </div>
<div class="row" v-if="item?.series"> <div class="row direct-url" v-if="computedItem?.imdb_url">
<div class="left side">TV Series</div> <div class="left side">ImDB URL</div>
<div class="right side" v-text="item.series" />
</div>
<div class="row" v-if="item?.season">
<div class="left side">Season</div>
<div class="right side" v-text="item.season" />
</div>
<div class="row" v-if="item?.episode">
<div class="left side">Episode</div>
<div class="right side" v-text="item.episode" />
</div>
<div class="row" v-if="item?.num_seasons">
<div class="left side">Number of seasons</div>
<div class="right side" v-text="item.num_seasons" />
</div>
<div class="row" v-if="item?.description">
<div class="left side">Description</div>
<div class="right side" v-text="item.description" />
</div>
<div class="row" v-if="item?.summary">
<div class="left side">Summary</div>
<div class="right side" v-text="item.summary" />
</div>
<div class="row" v-if="item?.overview">
<div class="left side">Overview</div>
<div class="right side" v-text="item.overview" />
</div>
<div class="row" v-if="item?.country">
<div class="left side">Country</div>
<div class="right side" v-text="item.country" />
</div>
<div class="row" v-if="item?.network">
<div class="left side">Network</div>
<div class="right side" v-text="item.network" />
</div>
<div class="row" v-if="item?.status">
<div class="left side">Status</div>
<div class="right side" v-text="item.status" />
</div>
<div class="row" v-if="item?.width && item?.height">
<div class="left side">Resolution</div>
<div class="right side"> <div class="right side">
{{ item.width }}x{{ item.height }} <a :href="computedItem.imdb_url" title="ImDB URL" target="_blank">
<i class="fas fa-external-link-alt" />
</a>
<button @click="copyToClipboard(computedItem.imdb_url)" title="Copy URL to clipboard">
<i class="fas fa-clipboard" />
</button>
</div> </div>
</div> </div>
<div class="row" v-if="item?.view_count != null"> <div class="row" v-if="computedItem?.artist?.name">
<div class="left side">Artist</div>
<div class="right side" v-text="computedItem.artist.name" />
</div>
<div class="row" v-if="computedItem?.album?.name">
<div class="left side">Album</div>
<div class="right side" v-text="computedItem.album.name" />
</div>
<div class="row" v-if="computedItem?.series">
<div class="left side">TV Series</div>
<div class="right side" v-text="computedItem.series" />
</div>
<div class="row" v-if="computedItem?.season">
<div class="left side">Season</div>
<div class="right side" v-text="computedItem.season" />
</div>
<div class="row" v-if="computedItem?.episode">
<div class="left side">Episode</div>
<div class="right side" v-text="computedItem.episode" />
</div>
<div class="row" v-if="computedItem?.num_seasons">
<div class="left side">Number of seasons</div>
<div class="right side" v-text="computedItem.num_seasons" />
</div>
<div class="row" v-if="computedItem?.description">
<div class="left side">Description</div>
<div class="right side" v-text="computedItem.description" />
</div>
<div class="row" v-if="computedItem?.summary">
<div class="left side">Summary</div>
<div class="right side" v-text="computedItem.summary" />
</div>
<div class="row" v-if="computedItem?.overview">
<div class="left side">Overview</div>
<div class="right side" v-text="computedItem.overview" />
</div>
<div class="row" v-if="computedItem?.country">
<div class="left side">Country</div>
<div class="right side" v-text="computedItem.country" />
</div>
<div class="row" v-if="computedItem?.network">
<div class="left side">Network</div>
<div class="right side" v-text="computedItem.network" />
</div>
<div class="row" v-if="computedItem?.status">
<div class="left side">Status</div>
<div class="right side" v-text="computedItem.status" />
</div>
<div class="row" v-if="computedItem?.width && computedItem?.height">
<div class="left side">Resolution</div>
<div class="right side">
{{ computedItem.width }}x{{ computedItem.height }}
</div>
</div>
<div class="row" v-if="computedItem?.view_count != null">
<div class="left side">Views</div> <div class="left side">Views</div>
<div class="right side">{{ formatNumber(item.view_count) }}</div> <div class="right side">{{ formatNumber(computedItem.view_count) }}</div>
</div> </div>
<div class="row" v-if="item?.rating"> <div class="row" v-if="computedItem?.rating">
<div class="left side">Rating</div> <div class="left side">Rating</div>
<div class="right side">{{ item.rating }}%</div> <div class="right side">{{ Math.round(computedItem.rating) }}%</div>
</div> </div>
<div class="row" v-if="item?.critic_rating"> <div class="row" v-if="computedItem?.critic_rating">
<div class="left side">Critic Rating</div> <div class="left side">Critic Rating</div>
<div class="right side">{{ item.critic_rating }}%</div> <div class="right side">{{ Math.round(computedItem.critic_rating) }}%</div>
</div> </div>
<div class="row" v-if="item?.community_rating"> <div class="row" v-if="computedItem?.community_rating">
<div class="left side">Community Rating</div> <div class="left side">Community Rating</div>
<div class="right side">{{ item.community_rating }}%</div> <div class="right side">{{ Math.round(computedItem.community_rating) }}%</div>
</div> </div>
<div class="row" v-if="item?.votes"> <div class="row" v-if="computedItem?.votes">
<div class="left side">Votes</div> <div class="left side">Votes</div>
<div class="right side" v-text="item.votes" /> <div class="right side" v-text="computedItem.votes" />
</div> </div>
<div class="row" v-if="item?.genres"> <div class="row" v-if="computedItem?.genres">
<div class="left side">Genres</div> <div class="left side">Genres</div>
<div class="right side" v-text="item.genres.join(', ')" /> <div class="right side" v-text="computedItem.genres.join(', ')" />
</div> </div>
<div class="row" v-if="channel"> <div class="row" v-if="channel">
@ -119,9 +143,9 @@
</div> </div>
</div> </div>
<div class="row" v-if="item?.year"> <div class="row" v-if="computedItem?.year">
<div class="left side">Year</div> <div class="left side">Year</div>
<div class="right side" v-text="item.year" /> <div class="right side" v-text="computedItem.year" />
</div> </div>
<div class="row" v-if="publishedDate"> <div class="row" v-if="publishedDate">
@ -129,46 +153,56 @@
<div class="right side" v-text="publishedDate" /> <div class="right side" v-text="publishedDate" />
</div> </div>
<div class="row" v-if="item?.file"> <div class="row" v-if="computedItem?.file">
<div class="left side">File</div> <div class="left side">File</div>
<div class="right side" v-text="item.file" /> <div class="right side" v-text="computedItem.file" />
</div> </div>
<div class="row" v-if="item?.trailer"> <div class="row" v-if="computedItem?.track_number != null">
<div class="left side">Track</div>
<div class="right side" v-text="computedItem.track_number" />
</div>
<div class="row" v-if="computedItem?.trailer">
<div class="left side">Trailer</div> <div class="left side">Trailer</div>
<div class="right side url"> <div class="right side url">
<a :href="item.trailer" target="_blank" v-text="item.trailer" /> <a :href="computedItem.trailer" target="_blank" v-text="computedItem.trailer" />
</div> </div>
</div> </div>
<div class="row" v-if="item?.size"> <div class="row" v-if="computedItem?.size">
<div class="left side">Size</div> <div class="left side">Size</div>
<div class="right side" v-text="convertSize(item.size)" /> <div class="right side" v-text="convertSize(computedItem.size)" />
</div> </div>
<div class="row" v-if="item?.quality"> <div class="row" v-if="computedItem?.quality">
<div class="left side">Quality</div> <div class="left side">Quality</div>
<div class="right side" v-text="item.quality" /> <div class="right side" v-text="computedItem.quality" />
</div> </div>
<div class="row" v-if="item?.seeds"> <div class="row" v-if="computedItem?.seeds">
<div class="left side">Seeds</div> <div class="left side">Seeds</div>
<div class="right side" v-text="item.seeds" /> <div class="right side" v-text="computedItem.seeds" />
</div> </div>
<div class="row" v-if="item?.peers"> <div class="row" v-if="computedItem?.peers">
<div class="left side">Peers</div> <div class="left side">Peers</div>
<div class="right side" v-text="item.peers" /> <div class="right side" v-text="computedItem.peers" />
</div> </div>
<div class="row" v-if="item?.language"> <div class="row" v-if="computedItem?.tags">
<div class="left side">Tags</div>
<div class="right side" v-text="computedItem.tags.join(', ')" />
</div>
<div class="row" v-if="computedItem?.language">
<div class="left side">Language</div> <div class="left side">Language</div>
<div class="right side" v-text="item.language" /> <div class="right side" v-text="computedItem.language" />
</div> </div>
<div class="row" v-if="item?.audio_channels"> <div class="row" v-if="computedItem?.audio_channels">
<div class="left side">Audio Channels</div> <div class="left side">Audio Channels</div>
<div class="right side" v-text="item.audio_channels" /> <div class="right side" v-text="computedItem.audio_channels" />
</div> </div>
</div> </div>
</template> </template>
@ -176,6 +210,7 @@
<script> <script>
import Icons from "./icons.json"; import Icons from "./icons.json";
import Item from "./Item"; import Item from "./Item";
import Loading from "@/components/Loading";
import MediaUtils from "@/components/Media/Utils"; import MediaUtils from "@/components/Media/Utils";
import Utils from "@/Utils"; import Utils from "@/Utils";
@ -183,6 +218,7 @@ export default {
name: "Info", name: "Info",
components: { components: {
Item, Item,
Loading,
}, },
mixins: [Utils, MediaUtils], mixins: [Utils, MediaUtils],
emits: [ emits: [
@ -207,8 +243,10 @@ export default {
data() { data() {
return { return {
typeIcons: Icons, typeIcons: Icons,
loading: false,
loadingUrl: false, loadingUrl: false,
youtubeUrl: null, youtubeUrl: null,
metadata: null,
} }
}, },
@ -235,6 +273,13 @@ export default {
return ret return ret
}, },
computedItem() {
return {
...(this.item || {}),
...(this.metadata || {}),
}
},
publishedDate() { publishedDate() {
if (this.item?.publishedAt) if (this.item?.publishedAt)
return this.formatDate(this.item.publishedAt, true) return this.formatDate(this.item.publishedAt, true)
@ -263,6 +308,35 @@ export default {
return this.item?.url return this.item?.url
}, },
}, },
methods: {
async updateMetadata() {
this.loading = true
try {
if (this.item?.type === 'jellyfin' && this.item?.id) {
this.metadata = await this.request('media.jellyfin.info', {
item_id: this.item.id,
})
}
} finally {
this.loading = false
}
},
},
watch: {
item: {
handler() {
this.updateMetadata()
},
deep: true,
},
},
mounted() {
this.updateMetadata()
},
} }
</script> </script>
@ -271,6 +345,7 @@ export default {
.media-info { .media-info {
width: 100%; width: 100%;
max-width: 60em;
} }
.row { .row {

View file

@ -1,35 +1,51 @@
<template> <template>
<div <div
class="item media-item" class="item media-item"
:class="{selected: selected}" :class="{selected: selected, 'list': listView}"
@click.right.prevent="$refs.dropdown.toggle()" @click.right.prevent="$refs.dropdown.toggle()"
v-if="!hidden"> v-if="!hidden">
<div class="thumbnail">
<div class="thumbnail" v-if="!listView">
<MediaImage :item="item" @play="$emit('play')" @select="$emit('select')" /> <MediaImage :item="item" @play="$emit('play')" @select="$emit('select')" />
</div> </div>
<div class="body"> <div class="body">
<div class="row title"> <div class="row title">
<div class="col-11 left side" v-text="item.title || item.name" @click="$emit('select')" /> <div class="left side"
<div class="col-1 right side"> :class="{'col-11': !listView, 'col-10': listView }"
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown"> @click.stop="$emit('select')">
<DropdownItem icon-class="fa fa-play" text="Play" @input="$emit('play')" <span class="track-number" v-if="listView && item.track_number">
v-if="item.type !== 'torrent'" /> {{ item.track_number }}
<DropdownItem icon-class="fa fa-play" text="Play (With Cache)" </span>
@input="$emit('play-with-opts', {item: item, opts: {cache: true}})"
v-if="item.type === 'youtube'" /> {{item.title || item.name}}
<DropdownItem icon-class="fa fa-download" text="Download" @input="$emit('download')" </div>
v-if="(item.type === 'torrent' || item.type === 'youtube') && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-volume-high" text="Download Audio" @input="$emit('download-audio')" <div class="right side" :class="{'col-1': !listView, 'col-2': listView }">
v-if="item.type === 'youtube' && item.item_type !== 'channel' && item.item_type !== 'playlist'" /> <span class="duration" v-if="item.duration && listView">
<DropdownItem icon-class="fa fa-list" text="Add to playlist" @input="$emit('add-to-playlist')" <span v-text="formatDuration(item.duration, true)" />
v-if="item.type === 'youtube'" /> </span>
<DropdownItem icon-class="fa fa-trash" text="Remove from playlist" @input="$emit('remove-from-playlist')"
v-if="item.type === 'youtube' && playlist?.length" /> <span class="actions">
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @input="$emit('view')" <Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown">
v-if="item.type === 'file'" /> <DropdownItem icon-class="fa fa-play" text="Play" @input="$emit('play')"
<DropdownItem icon-class="fa fa-info-circle" text="Info" @input="$emit('select')" /> v-if="item.type !== 'torrent'" />
</Dropdown> <DropdownItem icon-class="fa fa-play" text="Play (With Cache)"
@input="$emit('play-with-opts', {item: item, opts: {cache: true}})"
v-if="item.type === 'youtube'" />
<DropdownItem icon-class="fa fa-download" text="Download" @input="$emit('download')"
v-if="(item.type === 'torrent' || item.type === 'youtube') && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-volume-high" text="Download Audio" @input="$emit('download-audio')"
v-if="item.type === 'youtube' && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-list" text="Add to playlist" @input="$emit('add-to-playlist')"
v-if="item.type === 'youtube'" />
<DropdownItem icon-class="fa fa-trash" text="Remove from playlist" @input="$emit('remove-from-playlist')"
v-if="item.type === 'youtube' && playlist?.length" />
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @input="$emit('view')"
v-if="item.type === 'file'" />
<DropdownItem icon-class="fa fa-info-circle" text="Info" @input="$emit('select')" />
</Dropdown>
</span>
</div> </div>
</div> </div>
@ -40,11 +56,11 @@
</a> </a>
</div> </div>
<div class="row creation-date" v-if="item.created_at"> <div class="row creation-date" v-if="item.created_at && showDate">
{{ formatDateTime(item.created_at, true) }} {{ formatDateTime(item.created_at, true) }}
</div> </div>
<div class="row creation-date" v-text="item.year" v-else-if="item.year" /> <div class="row creation-date" v-text="item.year" v-else-if="item.year && showDate" />
<div class="row ratings" v-if="item.critic_rating != null || item.community_rating != null"> <div class="row ratings" v-if="item.critic_rating != null || item.community_rating != null">
<span class="rating" title="Critic rating" v-if="item.critic_rating != null"> <span class="rating" title="Critic rating" v-if="item.critic_rating != null">
@ -94,7 +110,7 @@ export default {
default: false, default: false,
}, },
selected: { listView: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
@ -102,6 +118,16 @@ export default {
playlist: { playlist: {
type: String, type: String,
}, },
selected: {
type: Boolean,
default: false,
},
showDate: {
type: Boolean,
default: true,
},
}, },
data() { data() {
@ -261,5 +287,47 @@ export default {
} }
} }
} }
&.list {
max-height: none;
border-bottom: 1px solid $default-shadow-color !important;
margin: 0;
padding: 0.25em 0.5em;
&:hover {
text-decoration: none;
}
.side {
display: flex;
align-items: center;
&.left {
max-height: none;
flex-direction: row;
overflow: visible;
}
&.right {
display: flex;
justify-content: flex-end;
margin-right: 0;
}
.duration {
font-size: .9em;
opacity: .75;
margin-right: 1em;
}
.track-number {
display: inline-flex;
font-size: .9em;
margin-right: 1em;
color: $default-fg-2;
justify-content: flex-end;
}
}
}
} }
</style> </style>

View file

@ -44,6 +44,7 @@ export default {
'download-audio', 'download-audio',
'play', 'play',
'play-with-opts', 'play-with-opts',
'select',
], ],
data() { data() {
@ -84,6 +85,8 @@ export default {
return 'movies' return 'movies'
case 'homevideos': case 'homevideos':
return 'videos' return 'videos'
case 'music':
return 'music'
default: default:
return 'index' return 'index'
} }

View file

@ -16,6 +16,10 @@
<div class="name" v-if="fallbackImageCollections[collection.id] || parentId"> <div class="name" v-if="fallbackImageCollections[collection.id] || parentId">
<h2>{{ collection.name }}</h2> <h2>{{ collection.name }}</h2>
</div> </div>
<div class="float bottom-right" v-if="collection.year">
<span>{{ collection.year }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -60,7 +64,17 @@ export default {
filteredItems() { filteredItems() {
return Object.values(this.items).filter( return Object.values(this.items).filter(
(item) => !this.filter || item.name.toLowerCase().includes(this.filter.toLowerCase()) (item) => !this.filter || item.name.toLowerCase().includes(this.filter.toLowerCase())
).sort((a, b) => a.name.localeCompare(b.name)) ).sort((a, b) => {
if (a.item_type === 'album' && b.item_type === 'album') {
if (a.year && b.year) {
if (a.year !== b.year) {
return b.year - a.year
}
}
}
return a.name.localeCompare(b.name)
})
}, },
}, },
@ -77,12 +91,29 @@ export default {
.index { .index {
.item { .item {
position: relative;
h2 { h2 {
font-size: 1.25em; font-size: 1.25em;
font-weight: bold; font-weight: bold;
overflow: auto; overflow: auto;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.float {
position: absolute;
background: rgba(0, 0, 0, 0.5);
color: white;
z-index: 1;
padding: 0.25em;
font-size: 0.9em;
border-radius: 0.5em;
&.bottom-right {
right: 0;
bottom: 0;
}
}
} }
&.is-root { &.is-root {

View file

@ -2,12 +2,23 @@
<div class="videos index"> <div class="videos index">
<Loading v-if="isLoading" /> <Loading v-if="isLoading" />
<div class="wrapper music-wrapper" v-else-if="collection?.collection_type === 'music'">
<Music :collection="collection"
:filter="filter"
:loading="isLoading"
:path="path"
@play="$emit('play', $event)"
@play-with-opts="$emit('play-with-opts', $event)"
@select="selectedResult = $event; $emit('select', $event)"
@select-collection="selectCollection" />
</div>
<NoItems :with-shadow="false" <NoItems :with-shadow="false"
v-else-if="!items?.length"> v-else-if="!items?.length">
No videos found. No videos found.
</NoItems> </NoItems>
<div class="items-wrapper" v-else> <div class="wrapper items-wrapper" v-else>
<Collections :collection="collection" <Collections :collection="collection"
:filter="filter" :filter="filter"
:items="collections" :items="collections"
@ -28,10 +39,6 @@
@select="selectedResult = $event" @select="selectedResult = $event"
v-if="mediaItems.length > 0" /> v-if="mediaItems.length > 0" />
</div> </div>
<SortButton :value="sort"
@input="sort = $event"
v-if="items.length > 0" />
</div> </div>
</template> </template>
@ -39,9 +46,9 @@
import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections"; import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections";
import Loading from "@/components/Loading"; import Loading from "@/components/Loading";
import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin"; import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin";
import Music from "../Music/Index";
import NoItems from "@/components/elements/NoItems"; import NoItems from "@/components/elements/NoItems";
import Results from "@/components/panels/Media/Results"; import Results from "@/components/panels/Media/Results";
import SortButton from "@/components/panels/Media/Providers/Jellyfin/components/SortButton";
export default { export default {
mixins: [Mixin], mixins: [Mixin],
@ -49,9 +56,9 @@ export default {
components: { components: {
Collections, Collections,
Loading, Loading,
Music,
NoItems, NoItems,
Results, Results,
SortButton,
}, },
computed: { computed: {
@ -92,18 +99,34 @@ export default {
}, },
async refresh() { async refresh() {
// Don't fetch items if we're in the music view -
// we'll fetch them in the Music component
if (this.collection?.collection_type === 'music')
return
this.loading_ = true this.loading_ = true
try { try {
this.items = this.collection?.id ? if (this.collection?.collection_type === 'tvshows') {
( this.items = (
await this.request('media.jellyfin.get_items', { await this.request('media.jellyfin.get_collections', {
parent_id: this.collection.id, parent_id: this.collection.id,
limit: 5000,
}) })
) : (await this.request('media.jellyfin.get_collections')).map((collection) => ({ ).map((collection) => ({
...collection, ...collection,
item_type: 'collection', item_type: 'collection',
})) }))
} else {
this.items = this.collection?.id ?
(
await this.request('media.jellyfin.get_items', {
parent_id: this.collection.id,
limit: 5000,
})
) : (await this.request('media.jellyfin.get_collections')).map((collection) => ({
...collection,
item_type: 'collection',
}))
}
} finally { } finally {
this.loading_ = false this.loading_ = false
} }
@ -136,5 +159,9 @@ export default {
overflow: hidden; overflow: hidden;
} }
} }
.music-wrapper {
height: 100%;
}
} }
</style> </style>

View file

@ -0,0 +1,411 @@
<template>
<div class="music index">
<Loading v-if="isLoading" />
<NoItems :with-shadow="false" v-else-if="!items?.length">
No music found.
</NoItems>
<main :class="{ album: view === 'album', artist: view === 'artist' }" v-else>
<div class="artist header" v-if="view === 'artist'">
<div class="image" v-if="collection.image">
<img :src="collection.image" />
</div>
<div class="info">
<h1 v-text="collection.name" />
</div>
</div>
<div class="album header" v-if="view === 'album'">
<div class="image" v-if="collection.image">
<img :src="collection.image" />
</div>
<div class="info">
<h1 v-text="collection.name" />
<div class="artist" v-if="displayedArtist?.id">
<a href="#" v-text="displayedArtist.name"
@click.prevent.stop="selectArtist"
v-if="displayedArtist" />
<span v-text="displayedArtist.name" v-else />
</div>
<div class="details">
<div class="row" v-if="collection.year">
<span class="label">Year:</span>
<span class="value" v-text="collection.year" />
</div>
<div class="row" v-if="collection.duration">
<span class="label">Duration:</span>
<span class="value" v-text="formatDuration(collection.duration, true)" />
</div>
</div>
</div>
</div>
<Collections :collection="collection"
:filter="filter"
:items="collections"
:loading="isLoading"
:parent-id="collection?.id"
@select="selectCollection"
v-if="collections?.length > 0" />
<Results :results="mediaItems"
:sources="{'jellyfin': true}"
:filter="filter"
:list-view="true"
:selected-result="selectedResult"
:show-date="false"
@add-to-playlist="$emit('add-to-playlist', $event)"
@download="$emit('download', $event)"
@play="$emit('play', $event)"
@play-with-opts="$emit('play-with-opts', $event)"
@remove-from-playlist="$emit('remove-from-playlist', $event)"
@select="selectedResult = $event"
v-if="mediaItems?.length > 0" />
</main>
</div>
</template>
<script>
import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections";
import Loading from "@/components/Loading";
import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin";
import NoItems from "@/components/elements/NoItems";
import Results from "@/components/panels/Media/Results";
export default {
mixins: [Mixin],
emits: ['select', 'select-collection'],
components: {
Collections,
Loading,
NoItems,
Results,
},
data() {
return {
artist: null,
}
},
computed: {
collections() {
return (
this.sortedItems?.filter((item) => ['collection', 'artist', 'album'].includes(item.item_type)) ?? []
).sort((a, b) => a.name.localeCompare(b.name))
},
displayedArtist() {
return this.artist || this.collection?.artist
},
mediaItems() {
return (
this.sortedItems?.filter((item) => !['collection', 'artist', 'album'].includes(item.item_type)) ?? []
).sort((a, b) => {
if (this.view === 'album') {
if (a.track_number && b.track_number) {
if (a.track_number !== b.track_number) {
return a.track_number - b.track_number
}
}
}
return a.name.localeCompare(b.name)
}).map((item) => {
if (this.view === 'album') {
item.artist = this.artist || this.collection.artist
item.album = this.collection
item.image = this.collection.image
}
return item
})
},
view() {
switch (this.collection?.item_type) {
case 'artist':
return 'artist'
case 'album':
return 'album'
default:
return 'index'
}
},
},
methods: {
async selectArtist() {
const artistId = this.displayedArtist?.id || this.getUrlArgs().artist
if (!artistId?.length)
return
this.loading_ = true
try {
const artist = this.displayedArtist || (await this.request('media.jellyfin.info', { item_id: artistId }))
if (artist) {
this.selectCollection(artist)
this.$nextTick(() => {
this.setUrlArgs({ artist: artist.id, collection: artist.id })
})
}
} finally {
this.loading_ = false
}
},
selectCollection(collection) {
if (!collection || collection?.id === this.collection?.id)
return
if (collection.item_type === 'artist') {
this.setUrlArgs({ artist: collection.id })
} else if (collection.item_type === 'album') {
this.setUrlArgs({ collection: collection.id })
} else {
this.setUrlArgs({ collection: null })
}
this.$emit('select-collection', {
collection_type: 'music',
...collection,
})
},
async init() {
const args = this.getUrlArgs()
let collection = args?.collection
if (!collection)
return
this.loading_ = true
try {
collection = await this.request('media.jellyfin.info', {
item_id: collection,
})
if (collection)
this.selectCollection(collection)
} finally {
this.loading_ = false
}
},
async refresh() {
this.loading_ = true
try {
switch (this.view) {
case 'artist':
this.artist = {...this.collection}
this.setUrlArgs({
artist: this.collection.id,
collection: this.collection.id,
})
this.items = (
await this.request(
'media.jellyfin.get_items',
{
parent_id: this.collection.id,
limit: 5000,
}
)
).map((item) => {
if (this.collection?.item_type === 'album') {
item.image = this.collection.image
}
return item
})
break
case 'album':
this.setUrlArgs({
collection: this.collection.id,
artist: this.collection.artist?.id,
})
this.items = await this.request(
'media.jellyfin.get_items',
{
parent_id: this.collection.id,
limit: 5000,
}
)
break
default:
this.artist = null
this.items = await this.request(
'media.jellyfin.get_artists',
{ limit: 5000 }
)
break
}
} finally {
this.loading_ = false
}
},
},
async mounted() {
await this.init()
await this.refresh()
},
unmounted() {
this.setUrlArgs({
collection: null,
artist: null,
album: null,
})
},
}
</script>
<style lang="scss" scoped>
@import "@/components/panels/Media/Providers/Jellyfin/common.scss";
$artist-header-height: 5em;
$album-header-height: 10em;
.index {
position: relative;
:deep(main) {
height: 100%;
position: relative;
overflow: auto;
&.artist, &.album {
overflow: hidden;
.media-results {
overflow: auto;
.grid {
height: fit-content;
max-height: 100%;
}
}
}
&.artist {
.index {
height: 100%;
.items {
height: calc(100% - #{$artist-header-height} - 0.5em);
overflow: auto;
}
}
}
&.album {
.media-results {
height: calc(100% - #{$album-header-height} - 0.5em);
}
}
.index {
height: fit-content;
}
.items {
overflow: hidden;
}
}
.header {
background: $info-header-bg;
display: flex;
align-items: center;
padding: 1em;
box-shadow: $border-shadow-bottom;
margin-bottom: 0.5em;
position: relative;
&.artist {
height: $artist-header-height;
padding: 0;
background: linear-gradient(rgba(0, 20, 25, 0.85), rgba(0, 0, 0, 0.85));
color: white;
.image {
width: $artist-header-height;
height: $artist-header-height;
img {
width: 100%;
height: 95%;
}
}
.info {
font-size: 1.25em;
}
}
&.album {
height: $album-header-height;
.image {
img {
width: $album-header-height;
height: $album-header-height;
}
}
}
.image {
margin-right: 1em;
img {
object-fit: cover;
margin: 0.25em;
}
}
.info {
h1 {
font-size: 1.5em;
margin: 0;
}
span {
font-size: 1.25em;
}
.artist {
a {
font-size: 1.25em;
}
}
.details {
font-size: 0.7em;
margin-top: 0.5em;
opacity: 0.75;
.row {
.label {
font-weight: bold;
margin-right: 0.5em;
}
}
}
}
}
:deep(.media-results.list ) {
.grid {
margin-top: -0.5em;
}
}
}
</style>

View file

@ -1,13 +1,15 @@
<template> <template>
<div class="media-results"> <div class="media-results" :class="{'list': listView}">
<Loading v-if="loading" /> <Loading v-if="loading" />
<div class="grid" ref="grid" v-if="results?.length" @scroll="onScroll"> <div class="grid" ref="grid" v-if="results?.length" @scroll="onScroll">
<Item v-for="(item, i) in visibleResults" <Item v-for="(item, i) in visibleResults"
:key="i" :key="i"
:hidden="!!Object.keys(sources || {}).length && !sources[item.type]" :hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
:item="item" :item="item"
:list-view="listView"
:playlist="playlist" :playlist="playlist"
:selected="selectedResult === i" :selected="selectedResult === i"
:show-date="showDate"
@add-to-playlist="$emit('add-to-playlist', item)" @add-to-playlist="$emit('add-to-playlist', item)"
@open-channel="$emit('open-channel', item)" @open-channel="$emit('open-channel', item)"
@remove-from-playlist="$emit('remove-from-playlist', item)" @remove-from-playlist="$emit('remove-from-playlist', item)"
@ -56,11 +58,25 @@ export default {
], ],
props: { props: {
filter: {
type: String,
default: null,
},
listView: {
type: Boolean,
default: false,
},
loading: { loading: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
playlist: {
default: null,
},
pluginName: { pluginName: {
type: String, type: String,
}, },
@ -70,27 +86,23 @@ export default {
default: () => [], default: () => [],
}, },
selectedResult: {
type: Number,
},
sources: {
type: Object,
default: () => {},
},
filter: {
type: String,
default: null,
},
resultIndexStep: { resultIndexStep: {
type: Number, type: Number,
default: 25, default: 25,
}, },
playlist: { selectedResult: {
default: null, type: Number,
},
showDate: {
type: Boolean,
default: true,
},
sources: {
type: Object,
default: () => {},
}, },
}, },
@ -107,7 +119,7 @@ export default {
if (!this.filter?.length) if (!this.filter?.length)
return true return true
return item.title.toLowerCase().includes(this.filter.toLowerCase()) return (item.title || item.name).toLowerCase().includes(this.filter.toLowerCase())
}) })
if (this.maxResultIndex != null) if (this.maxResultIndex != null)
@ -134,18 +146,18 @@ export default {
}, },
}, },
mounted() { watch: {
this.$watch('selectedResult', (value) => { selectedResult(value) {
if (value?.item_type === 'playlist' || value?.item_type === 'channel') { if (value?.item_type === 'playlist' || value?.item_type === 'channel') {
this.$emit('select', null) this.$emit('select', null)
return return
} }
if (value == null) if (this.selectedResult == null)
this.$refs.infoModal?.close() this.$refs.infoModal?.close()
else else
this.$refs.infoModal?.show() this.$refs.infoModal?.show()
}) },
}, },
} }
</script> </script>
@ -169,5 +181,18 @@ export default {
width: 100%; width: 100%;
cursor: initial; cursor: initial;
} }
&.list {
:deep(.grid) {
display: flex;
flex-direction: column;
padding: 0;
row-gap: 0;
.title {
font-weight: normal;
}
}
}
} }
</style> </style>