[YouTube UI] Added support for browsing playlists from search results.

This commit is contained in:
Fabio Manganiello 2024-07-12 03:10:43 +02:00
parent 7c610413df
commit e65bf99baf
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
8 changed files with 310 additions and 98 deletions

View file

@ -23,8 +23,9 @@
<component <component
:is="mediaProvider" :is="mediaProvider"
:filter="filter" :filter="filter"
:selected-playlist="selectedPlaylist"
@add-to-playlist="$emit('add-to-playlist', $event)" @add-to-playlist="$emit('add-to-playlist', $event)"
@back="mediaProvider = null" @back="back"
@path-change="$emit('path-change', $event)" @path-change="$emit('path-change', $event)"
@play="$emit('play', $event)" @play="$emit('play', $event)"
/> />
@ -44,6 +45,7 @@ export default {
mixins: [Utils], mixins: [Utils],
emits: [ emits: [
'add-to-playlist', 'add-to-playlist',
'back',
'create-playlist', 'create-playlist',
'path-change', 'path-change',
'play', 'play',
@ -62,6 +64,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
selectedPlaylist: {
type: Object,
},
}, },
data() { data() {
@ -73,7 +79,22 @@ export default {
} }
}, },
computed: {
mediaProvidersLookup() {
return Object.keys(this.mediaProviders)
.reduce((acc, key) => {
acc[key.toLowerCase()] = key
return acc
}, {})
},
},
methods: { methods: {
back() {
this.mediaProvider = null
this.$emit('back')
},
registerMediaProvider(type) { registerMediaProvider(type) {
const component = shallowRef( const component = shallowRef(
defineAsyncComponent( defineAsyncComponent(
@ -94,10 +115,29 @@ export default {
if (config.youtube) if (config.youtube)
this.registerMediaProvider('YouTube') this.registerMediaProvider('YouTube')
}, },
async onPlaylistChange() {
if (!this.selectedPlaylist)
return
const playlistType = this.selectedPlaylist.type?.toLowerCase()
const playlistMediaProvider = this.mediaProvidersLookup[playlistType]
if (playlistMediaProvider) {
this.mediaProvider = this.mediaProviders[playlistMediaProvider]
}
},
}, },
mounted() { watch: {
this.refreshMediaProviders() selectedPlaylist() {
this.onPlaylistChange()
},
},
async mounted() {
await this.refreshMediaProviders()
await this.onPlaylistChange()
}, },
} }
</script> </script>

View file

@ -8,7 +8,7 @@
<div class="nav-container from tablet" :style="navContainerStyle"> <div class="nav-container from tablet" :style="navContainerStyle">
<Nav :selected-view="selectedView" <Nav :selected-view="selectedView"
:torrent-plugin="torrentPlugin" :torrent-plugin="torrentPlugin"
@input="selectedView = $event" @input="onNavInput"
@toggle="forceShowNav = !forceShowNav" /> @toggle="forceShowNav = !forceShowNav" />
</div> </div>
@ -52,7 +52,9 @@
v-else-if="selectedView === 'torrents'" /> v-else-if="selectedView === 'torrents'" />
<Browser :filter="browserFilter" <Browser :filter="browserFilter"
:selected-playlist="selectedPlaylist"
@add-to-playlist="addToPlaylistItem = $event" @add-to-playlist="addToPlaylistItem = $event"
@back="selectedResult = null"
@path-change="browserFilter = ''" @path-change="browserFilter = ''"
@play="play($event)" @play="play($event)"
v-else-if="selectedView === 'browser'" /> v-else-if="selectedView === 'browser'" />
@ -195,6 +197,17 @@ export default {
return this.results[this.selectedResult] return this.results[this.selectedResult]
}, },
selectedPlaylist() {
if (this.selectedResult == null)
return null
const selectedItem = this.results[this.selectedResult]
if (selectedItem?.item_type !== 'playlist')
return null
return this.results[this.selectedResult]
},
}, },
methods: { methods: {
@ -396,15 +409,20 @@ export default {
this.selectedResult = null this.selectedResult = null
} }
if (this.selectedResult != null && this.results[this.selectedResult]?.item_type === 'playlist') { const selectedItem = this.results[this.selectedResult]
if (this.selectedResult != null && selectedItem?.item_type === 'playlist') {
this.onPlaylistSelect()
} else {
this.selectedView = this.prevSelectedView || 'search'
}
},
onPlaylistSelect() {
if (this.prevSelectedView != this.selectedView) { if (this.prevSelectedView != this.selectedView) {
this.prevSelectedView = this.selectedView this.prevSelectedView = this.selectedView
} }
this.selectedView = 'browser' this.selectedView = 'browser'
} else {
this.selectedView = this.prevSelectedView || 'search'
}
}, },
showPlayUrlModal() { showPlayUrlModal() {
@ -425,6 +443,13 @@ export default {
this.loading = false this.loading = false
} }
}, },
onNavInput(event) {
this.selectedView = event
if (event === 'search') {
this.selectedResult = null
}
},
}, },
mounted() { mounted() {

View file

@ -19,6 +19,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
selectedPlaylist: {
default: null,
},
}, },
data() { data() {

View file

@ -14,7 +14,7 @@
/> />
<Playlists :filter="filter" <Playlists :filter="filter"
:selected-playlist="selectedPlaylist" :selected-playlist="selectedPlaylist_"
@add-to-playlist="$emit('add-to-playlist', $event)" @add-to-playlist="$emit('add-to-playlist', $event)"
@play="$emit('play', $event)" @play="$emit('play', $event)"
@remove-from-playlist="removeFromPlaylist" @remove-from-playlist="removeFromPlaylist"
@ -62,7 +62,7 @@ export default {
return { return {
youtubeConfig: null, youtubeConfig: null,
selectedView: null, selectedView: null,
selectedPlaylist: null, selectedPlaylist_: null,
selectedChannel: null, selectedChannel: null,
path: [], path: [],
} }
@ -124,7 +124,7 @@ export default {
selectView(view) { selectView(view) {
this.selectedView = view this.selectedView = view
if (view === 'playlists') if (view === 'playlists')
this.selectedPlaylist = null this.selectedPlaylist_ = null
else if (view === 'subscriptions') else if (view === 'subscriptions')
this.selectedChannel = null this.selectedChannel = null
@ -141,7 +141,11 @@ export default {
}, },
onPlaylistSelected(playlist) { onPlaylistSelected(playlist) {
this.selectedPlaylist = playlist.id this.selectedPlaylist_ = playlist
if (!playlist)
return
this.selectedView = 'playlists'
this.path.push({ this.path.push({
title: playlist.name, title: playlist.name,
}) })
@ -155,8 +159,15 @@ export default {
}, },
}, },
watch: {
selectedPlaylist() {
this.onPlaylistSelected(this.selectedPlaylist)
},
},
mounted() { mounted() {
this.loadYoutubeConfig() this.loadYoutubeConfig()
this.onPlaylistSelected(this.selectedPlaylist)
}, },
} }
</script> </script>

View file

@ -8,18 +8,23 @@
<img :src="channel.banner" v-if="channel?.banner?.length" /> <img :src="channel.banner" v-if="channel?.banner?.length" />
</div> </div>
<div class="row info-container">
<div class="info">
<div class="row"> <div class="row">
<a :href="channel.url" target="_blank" rel="noopener noreferrer"> <a :href="channel.url" target="_blank" rel="noopener noreferrer" v-if="channel?.image?.length">
<div class="image"> <div class="image">
<img :src="channel.image" v-if="channel?.image?.length" /> <img :src="channel.image" />
</div> </div>
</a> </a>
<div class="info">
<a class="title" :href="channel.url" target="_blank" rel="noopener noreferrer"> <a class="title" :href="channel.url" target="_blank" rel="noopener noreferrer">
{{ channel?.name }} {{ channel?.name }}
</a> </a>
<div class="description">{{ channel?.description }}</div> </div>
<div class="description" v-if="channel?.description">
{{ channel.description }}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -125,64 +130,14 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "header.scss";
.media-youtube-channel { .media-youtube-channel {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
.header { .channel {
border-bottom: $default-border-2;
padding-bottom: 0.5em;
.banner {
max-height: 200px;
display: flex;
justify-content: center;
img {
max-width: 100%;
max-height: 100%;
}
}
.image {
height: 100px;
margin: -2.5em 2em 0.5em 0.5em;
img {
height: 100%; height: 100%;
border-radius: 50%;
}
}
.row {
display: flex;
@include from($desktop) {
flex-direction: row;
}
.info {
display: flex;
flex-direction: column;
}
}
.title {
color: $default-fg-2;
font-size: 1.7em;
font-weight: bold;
margin: 0.5em 0;
text-decoration: dotted;
&:hover {
color: $default-hover-fg;
}
}
.description {
font-size: 0.9em;
margin-right: 0.5em;
}
} }
} }
</style> </style>

View file

@ -1,7 +1,46 @@
<template> <template>
<div class="media-youtube-playlist"> <div class="media-youtube-playlist">
<Loading v-if="loading" /> <Loading v-if="loading" />
<NoItems :with-shadow="false" v-else-if="!items?.length">
<div class="playlist-container" v-else>
<div class="header">
<div class="banner">
<img :src="metadata?.image" v-if="metadata?.image?.length" />
</div>
<div class="row info-container">
<div class="info">
<div class="row">
<a class="title" :href="metadata?.url" target="_blank" rel="noopener noreferrer" v-if="metadata?.url">
{{ name }}
</a>
<span class="title" v-else>
{{ name }}
</span>
<div class="n-items">{{ nItems }} videos</div>
</div>
<div class="row" v-if="metadata?.description">
<div class="description">
{{ metadata?.description }}
</div>
</div>
<div class="row" v-if="metadata?.channel_url">
<div class="channel">
Uploaded by
<a :href="metadata.channel_url" target="_blank" rel="noopener noreferrer">
{{ metadata?.channel }}
</a>
</div>
</div>
</div>
</div>
</div>
<NoItems :with-shadow="false" v-if="!nItems">
No videos found. No videos found.
</NoItems> </NoItems>
@ -16,6 +55,7 @@
@select="selectedResult = $event" @select="selectedResult = $event"
v-else /> v-else />
</div> </div>
</div>
</template> </template>
<script> <script>
@ -49,7 +89,7 @@ export default {
default: null, default: null,
}, },
playlist: { metadata: {
type: Object, type: Object,
default: null, default: null,
}, },
@ -63,6 +103,16 @@ export default {
} }
}, },
computed: {
name() {
return this.metadata?.title || this.metadata?.name
},
nItems() {
return this.metadata?.videos || this.items?.length || 0
},
},
methods: { methods: {
async loadItems() { async loadItems() {
this.loading = true this.loading = true
@ -86,7 +136,25 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "header.scss";
.media-youtube-playlist { .media-youtube-playlist {
height: 100%; height: 100%;
.playlist-container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
.banner {
opacity: 0.75;
}
.channel {
flex: 1;
}
}
} }
</style> </style>

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="media-youtube-playlists"> <div class="media-youtube-playlists">
<div class="playlists-index" v-if="!selectedPlaylist"> <div class="playlists-index" v-if="!selectedPlaylist?.id">
<Loading v-if="loading" /> <Loading v-if="loading" />
<NoItems :with-shadow="false" v-else-if="!playlists?.length"> <NoItems :with-shadow="false" v-else-if="!playlists?.length">
No playlists found. No playlists found.
@ -27,11 +27,11 @@
<div class="playlist-body" v-else> <div class="playlist-body" v-else>
<Playlist <Playlist
:id="selectedPlaylist" :id="selectedPlaylist.id"
:filter="filter" :filter="filter"
:playlist="playlist" :metadata="playlistsById[selectedPlaylist.id] || selectedPlaylist"
@add-to-playlist="$emit('add-to-playlist', $event)" @add-to-playlist="$emit('add-to-playlist', $event)"
@remove-from-playlist="$emit('remove-from-playlist', {item: $event, playlist_id: selectedPlaylist})" @remove-from-playlist="$emit('remove-from-playlist', {item: $event, playlist_id: selectedPlaylist.id})"
@play="$emit('play', $event)" @play="$emit('play', $event)"
/> />
</div> </div>
@ -130,7 +130,7 @@ export default {
props: { props: {
selectedPlaylist: { selectedPlaylist: {
type: String, type: Object,
default: null, default: null,
}, },

View file

@ -0,0 +1,109 @@
$banner-height: 100px;
$info-bg: rgba(0, 0, 0, 0.5);
$info-fg: rgba(255, 255, 255, 0.9);
.header {
border-bottom: $default-border-2;
margin-bottom: 0.5em;
position: relative;
overflow-y: auto;
.banner {
height: $banner-height;
display: flex;
background-color: black;
justify-content: center;
img {
max-width: 800px;
max-height: 100%;
flex: 1;
}
}
.image {
height: 100px;
margin: -2.5em 2em 0.5em 0.5em;
img {
height: 100%;
border-radius: 50%;
}
}
.row {
display: flex;
@include from($desktop) {
flex-direction: row;
}
.info {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
background-color: $info-bg;
.row {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
padding: 0 0.5em;
}
}
}
.info-container {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: $info-bg;
color: $info-fg;
display: flex;
flex-direction: column;
a {
color: $info-fg !important;
&:hover {
color: $default-hover-fg !important;
}
}
.title {
letter-spacing: 0.1em;
color: $info-fg !important;
}
.n-items {
/* Align to the right */
margin-left: auto;
padding: 0 0.5em;
}
}
.title {
color: $default-fg-2;
font-size: 1.7em;
font-weight: bold;
margin: 0.5em 0;
text-decoration: dotted;
&:hover {
color: $default-hover-fg;
}
}
.description {
font-size: 0.9em;
margin-right: 0.5em;
}
}
.media-results {
height: calc(100% - #{$banner-height} - 1em);
}