diff --git a/platypush/backend/http/webapp/src/components/Media/Controls.vue b/platypush/backend/http/webapp/src/components/Media/Controls.vue index 293335834..222cdf230 100644 --- a/platypush/backend/http/webapp/src/components/Media/Controls.vue +++ b/platypush/backend/http/webapp/src/components/Media/Controls.vue @@ -4,7 +4,7 @@ @click.prevent="searchAlbum" v-if="status?.state !== 'stop'"> <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 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-info" @click="$emit('info', track)" v-if="track && status?.state !== 'stop'"> <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 class="title-container"> <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> - <a v-text="track.title?.length ? track.title : '[No Title]'" v-else-if="track.url"></a> - <span v-text="track.title?.length ? track.title : '[No Title]' " v-else></span> + <a v-text="trackTitle" v-else-if="track.url"></a> + <span v-text="trackTitle" v-else></span> </div> - <div class="artist" v-if="track.artist?.length && (status.state === 'play' || status.state === 'pause')"> - <a v-text="track.artist" @click.prevent="searchArtist"></a> + <div class="artist" v-if="trackArtistName?.length && (status.state === 'play' || status.state === 'pause')"> + <a v-text="trackArtistName" @click.prevent="searchArtist"></a> </div> </div> </div> @@ -206,12 +206,27 @@ export default { 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() { if (this.track?.images?.length) return this.track.images[0].url return this.track?.image || this.image }, + + trackTitle() { + return this.track?.title || this.track?.name || '[No Title]' + }, }, methods: { @@ -235,11 +250,11 @@ export default { }, searchArtist() { - if (!this.track?.artist) + if (!this.trackArtistName?.length) return const args = { - artist: this.track.artist, + artist: this.trackArtistName, } if (this.track.artist_uri) diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Index.vue b/platypush/backend/http/webapp/src/components/panels/Media/Index.vue index f74729dc7..efebfa2b2 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Index.vue @@ -95,6 +95,10 @@ /> </div> </div> + + <div class="media-loading-indicator" v-if="opening"> + <Loading /> + </div> </main> </MediaView> @@ -149,6 +153,7 @@ import Utils from "@/Utils"; import Browser from "@/components/panels/Media/Browser"; import Header from "@/components/panels/Media/Header"; import Info from "@/components/panels/Media/Info"; +import Loading from "@/components/Loading"; import MediaDownloads from "@/components/panels/Media/Downloads"; import MediaUtils from "@/components/Media/Utils"; import MediaView from "@/components/Media/View"; @@ -166,6 +171,7 @@ export default { Browser, Header, Info, + Loading, MediaDownloads, MediaView, Modal, @@ -205,6 +211,7 @@ export default { forceShowNav: false, infoTrack: null, loading: false, + opening: false, prevSelectedView: null, results: [], selectedPlayer: null, @@ -324,7 +331,7 @@ export default { return } - this.loading = true + this.opening = true try { if (!this.selectedPlayer.component.supports(item)) @@ -334,9 +341,9 @@ export default { item, this.selectedSubtitles, this.selectedPlayer, opts ) - await this.refresh() + await this.refresh(item) } finally { - this.loading = false + this.opening = false } }, @@ -383,15 +390,36 @@ export default { await this.download(item, {onlyAudio: true}) }, - async refresh() { - this.selectedPlayer.status = await this.selectedPlayer.component.status(this.selectedPlayer) + async refresh(item) { + 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) { if (!this.selectedPlayer) return - this.selectedPlayer.status = status + this.setStatus(status) }, onPlayUrlModalOpen() { @@ -558,17 +586,7 @@ export default { async playUrl(url) { this.urlPlay = url - this.loading = true - - try { - await this.play({ - url: url, - }) - - this.$refs.playUrlModal.close() - } finally { - this.loading = false - } + await this.play({ url: url }) }, async refreshDownloads() { @@ -788,6 +806,7 @@ export default { height: 100%; display: flex; flex-direction: row-reverse; + position: relative; .view-container { display: flex; @@ -806,6 +825,24 @@ export default { 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%; + } + } } } diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Info.vue b/platypush/backend/http/webapp/src/components/panels/Media/Info.vue index 575ef3226..6dd195fbc 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Info.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Info.vue @@ -1,5 +1,7 @@ <template> <div class="media-info"> + <Loading v-if="loading" /> + <div class="row header"> <div class="item-container"> <Item :item="item" @@ -25,91 +27,113 @@ </div> </div> - <div class="row" v-if="item?.series"> - <div class="left side">TV Series</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="row direct-url" v-if="computedItem?.imdb_url"> + <div class="left side">ImDB URL</div> <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 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="right side">{{ formatNumber(item.view_count) }}</div> + <div class="right side">{{ formatNumber(computedItem.view_count) }}</div> </div> - <div class="row" v-if="item?.rating"> + <div class="row" v-if="computedItem?.rating"> <div class="left side">Rating</div> - <div class="right side">{{ item.rating }}%</div> + <div class="right side">{{ Math.round(computedItem.rating) }}%</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="right side">{{ item.critic_rating }}%</div> + <div class="right side">{{ Math.round(computedItem.critic_rating) }}%</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="right side">{{ item.community_rating }}%</div> + <div class="right side">{{ Math.round(computedItem.community_rating) }}%</div> </div> - <div class="row" v-if="item?.votes"> + <div class="row" v-if="computedItem?.votes"> <div class="left side">Votes</div> - <div class="right side" v-text="item.votes" /> + <div class="right side" v-text="computedItem.votes" /> </div> - <div class="row" v-if="item?.genres"> + <div class="row" v-if="computedItem?.genres"> <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 class="row" v-if="channel"> @@ -119,9 +143,9 @@ </div> </div> - <div class="row" v-if="item?.year"> + <div class="row" v-if="computedItem?.year"> <div class="left side">Year</div> - <div class="right side" v-text="item.year" /> + <div class="right side" v-text="computedItem.year" /> </div> <div class="row" v-if="publishedDate"> @@ -129,46 +153,56 @@ <div class="right side" v-text="publishedDate" /> </div> - <div class="row" v-if="item?.file"> + <div class="row" v-if="computedItem?.file"> <div class="left side">File</div> - <div class="right side" v-text="item.file" /> + <div class="right side" v-text="computedItem.file" /> </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="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 class="row" v-if="item?.size"> + <div class="row" v-if="computedItem?.size"> <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 class="row" v-if="item?.quality"> + <div class="row" v-if="computedItem?.quality"> <div class="left side">Quality</div> - <div class="right side" v-text="item.quality" /> + <div class="right side" v-text="computedItem.quality" /> </div> - <div class="row" v-if="item?.seeds"> + <div class="row" v-if="computedItem?.seeds"> <div class="left side">Seeds</div> - <div class="right side" v-text="item.seeds" /> + <div class="right side" v-text="computedItem.seeds" /> </div> - <div class="row" v-if="item?.peers"> + <div class="row" v-if="computedItem?.peers"> <div class="left side">Peers</div> - <div class="right side" v-text="item.peers" /> + <div class="right side" v-text="computedItem.peers" /> </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="right side" v-text="item.language" /> + <div class="right side" v-text="computedItem.language" /> </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="right side" v-text="item.audio_channels" /> + <div class="right side" v-text="computedItem.audio_channels" /> </div> </div> </template> @@ -176,6 +210,7 @@ <script> import Icons from "./icons.json"; import Item from "./Item"; +import Loading from "@/components/Loading"; import MediaUtils from "@/components/Media/Utils"; import Utils from "@/Utils"; @@ -183,6 +218,7 @@ export default { name: "Info", components: { Item, + Loading, }, mixins: [Utils, MediaUtils], emits: [ @@ -207,8 +243,10 @@ export default { data() { return { typeIcons: Icons, + loading: false, loadingUrl: false, youtubeUrl: null, + metadata: null, } }, @@ -235,6 +273,13 @@ export default { return ret }, + computedItem() { + return { + ...(this.item || {}), + ...(this.metadata || {}), + } + }, + publishedDate() { if (this.item?.publishedAt) return this.formatDate(this.item.publishedAt, true) @@ -263,6 +308,35 @@ export default { 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> @@ -271,6 +345,7 @@ export default { .media-info { width: 100%; + max-width: 60em; } .row { diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Item.vue b/platypush/backend/http/webapp/src/components/panels/Media/Item.vue index 1f1b73be5..710e43acb 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Item.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Item.vue @@ -1,35 +1,51 @@ <template> <div class="item media-item" - :class="{selected: selected}" + :class="{selected: selected, 'list': listView}" @click.right.prevent="$refs.dropdown.toggle()" v-if="!hidden"> - <div class="thumbnail"> + + <div class="thumbnail" v-if="!listView"> <MediaImage :item="item" @play="$emit('play')" @select="$emit('select')" /> </div> <div class="body"> <div class="row title"> - <div class="col-11 left side" v-text="item.title || item.name" @click="$emit('select')" /> - <div class="col-1 right side"> - <Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown"> - <DropdownItem icon-class="fa fa-play" text="Play" @input="$emit('play')" - v-if="item.type !== 'torrent'" /> - <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> + <div class="left side" + :class="{'col-11': !listView, 'col-10': listView }" + @click.stop="$emit('select')"> + <span class="track-number" v-if="listView && item.track_number"> + {{ item.track_number }} + </span> + + {{item.title || item.name}} + </div> + + <div class="right side" :class="{'col-1': !listView, 'col-2': listView }"> + <span class="duration" v-if="item.duration && listView"> + <span v-text="formatDuration(item.duration, true)" /> + </span> + + <span class="actions"> + <Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown"> + <DropdownItem icon-class="fa fa-play" text="Play" @input="$emit('play')" + v-if="item.type !== 'torrent'" /> + <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> @@ -40,11 +56,11 @@ </a> </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) }} </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"> <span class="rating" title="Critic rating" v-if="item.critic_rating != null"> @@ -94,7 +110,7 @@ export default { default: false, }, - selected: { + listView: { type: Boolean, default: false, }, @@ -102,6 +118,16 @@ export default { playlist: { type: String, }, + + selected: { + type: Boolean, + default: false, + }, + + showDate: { + type: Boolean, + default: true, + }, }, 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> diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin.vue index 72da23e94..6c2507947 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin.vue @@ -44,6 +44,7 @@ export default { 'download-audio', 'play', 'play-with-opts', + 'select', ], data() { @@ -84,6 +85,8 @@ export default { return 'movies' case 'homevideos': return 'videos' + case 'music': + return 'music' default: return 'index' } diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/Collections.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/Collections.vue index 523f294c9..c5ab06a89 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/Collections.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/Collections.vue @@ -16,6 +16,10 @@ <div class="name" v-if="fallbackImageCollections[collection.id] || parentId"> <h2>{{ collection.name }}</h2> </div> + + <div class="float bottom-right" v-if="collection.year"> + <span>{{ collection.year }}</span> + </div> </div> </div> </div> @@ -60,7 +64,17 @@ export default { filteredItems() { return Object.values(this.items).filter( (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 { .item { + position: relative; + h2 { font-size: 1.25em; font-weight: bold; overflow: auto; 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 { diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Media/Index.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Media/Index.vue index 0bdf26d5e..b96a1e173 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Media/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Media/Index.vue @@ -2,12 +2,23 @@ <div class="videos index"> <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" v-else-if="!items?.length"> No videos found. </NoItems> - <div class="items-wrapper" v-else> + <div class="wrapper items-wrapper" v-else> <Collections :collection="collection" :filter="filter" :items="collections" @@ -28,10 +39,6 @@ @select="selectedResult = $event" v-if="mediaItems.length > 0" /> </div> - - <SortButton :value="sort" - @input="sort = $event" - v-if="items.length > 0" /> </div> </template> @@ -39,9 +46,9 @@ import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections"; import Loading from "@/components/Loading"; import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin"; +import Music from "../Music/Index"; import NoItems from "@/components/elements/NoItems"; import Results from "@/components/panels/Media/Results"; -import SortButton from "@/components/panels/Media/Providers/Jellyfin/components/SortButton"; export default { mixins: [Mixin], @@ -49,9 +56,9 @@ export default { components: { Collections, Loading, + Music, NoItems, Results, - SortButton, }, computed: { @@ -92,18 +99,34 @@ export default { }, 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 try { - this.items = this.collection?.id ? - ( - await this.request('media.jellyfin.get_items', { + if (this.collection?.collection_type === 'tvshows') { + this.items = ( + await this.request('media.jellyfin.get_collections', { parent_id: this.collection.id, - limit: 5000, }) - ) : (await this.request('media.jellyfin.get_collections')).map((collection) => ({ + ).map((collection) => ({ ...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 { this.loading_ = false } @@ -136,5 +159,9 @@ export default { overflow: hidden; } } + + .music-wrapper { + height: 100%; + } } </style> diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Music/Index.vue b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Music/Index.vue new file mode 100644 index 000000000..2ccca1822 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Media/Providers/Jellyfin/views/Music/Index.vue @@ -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> diff --git a/platypush/backend/http/webapp/src/components/panels/Media/Results.vue b/platypush/backend/http/webapp/src/components/panels/Media/Results.vue index 32036754a..19e9a38c3 100644 --- a/platypush/backend/http/webapp/src/components/panels/Media/Results.vue +++ b/platypush/backend/http/webapp/src/components/panels/Media/Results.vue @@ -1,13 +1,15 @@ <template> - <div class="media-results"> + <div class="media-results" :class="{'list': listView}"> <Loading v-if="loading" /> <div class="grid" ref="grid" v-if="results?.length" @scroll="onScroll"> <Item v-for="(item, i) in visibleResults" :key="i" :hidden="!!Object.keys(sources || {}).length && !sources[item.type]" :item="item" + :list-view="listView" :playlist="playlist" :selected="selectedResult === i" + :show-date="showDate" @add-to-playlist="$emit('add-to-playlist', item)" @open-channel="$emit('open-channel', item)" @remove-from-playlist="$emit('remove-from-playlist', item)" @@ -56,11 +58,25 @@ export default { ], props: { + filter: { + type: String, + default: null, + }, + + listView: { + type: Boolean, + default: false, + }, + loading: { type: Boolean, default: false, }, + playlist: { + default: null, + }, + pluginName: { type: String, }, @@ -70,27 +86,23 @@ export default { default: () => [], }, - selectedResult: { - type: Number, - }, - - sources: { - type: Object, - default: () => {}, - }, - - filter: { - type: String, - default: null, - }, - resultIndexStep: { type: Number, default: 25, }, - playlist: { - default: null, + selectedResult: { + type: Number, + }, + + showDate: { + type: Boolean, + default: true, + }, + + sources: { + type: Object, + default: () => {}, }, }, @@ -107,7 +119,7 @@ export default { if (!this.filter?.length) return true - return item.title.toLowerCase().includes(this.filter.toLowerCase()) + return (item.title || item.name).toLowerCase().includes(this.filter.toLowerCase()) }) if (this.maxResultIndex != null) @@ -134,18 +146,18 @@ export default { }, }, - mounted() { - this.$watch('selectedResult', (value) => { + watch: { + selectedResult(value) { if (value?.item_type === 'playlist' || value?.item_type === 'channel') { this.$emit('select', null) return } - if (value == null) + if (this.selectedResult == null) this.$refs.infoModal?.close() else this.$refs.infoModal?.show() - }) + }, }, } </script> @@ -169,5 +181,18 @@ export default { width: 100%; cursor: initial; } + + &.list { + :deep(.grid) { + display: flex; + flex-direction: column; + padding: 0; + row-gap: 0; + + .title { + font-weight: normal; + } + } + } } </style>