forked from platypush/platypush
Merge pull request 'New YouTube UI features' (#411) from 391/improve-youtube-support into master
Reviewed-on: platypush/platypush#411 Closes: #391
This commit is contained in:
commit
484959a153
31 changed files with 2087 additions and 211 deletions
|
@ -134,6 +134,7 @@ export default {
|
||||||
try {
|
try {
|
||||||
this.files = await this.request('file.list', {path: this.path})
|
this.files = await this.request('file.list', {path: this.path})
|
||||||
this.$emit('path-change', this.path)
|
this.$emit('path-change', this.path)
|
||||||
|
this.setUrlArgs({path: decodeURIComponent(this.path)})
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
|
@ -165,8 +166,16 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
const args = this.getUrlArgs()
|
||||||
|
if (args.path)
|
||||||
|
this.path = args.path
|
||||||
|
|
||||||
this.refresh()
|
this.refresh()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
this.setUrlArgs({path: null})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Modal ref="modal" :title="title">
|
<Modal ref="modal" :title="title" @close="close">
|
||||||
<div class="dialog-content">
|
<div class="dialog-content">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['input', 'click', 'touch'],
|
emits: ['input', 'click', 'close', 'touch'],
|
||||||
components: {Modal},
|
components: {Modal},
|
||||||
props: {
|
props: {
|
||||||
title: {
|
title: {
|
||||||
|
@ -43,12 +43,21 @@ export default {
|
||||||
this.close()
|
this.close()
|
||||||
},
|
},
|
||||||
|
|
||||||
show() {
|
open() {
|
||||||
this.$refs.modal.show()
|
this.$refs.modal.show()
|
||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.$refs.modal.hide()
|
this.$refs.modal.hide()
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.open()
|
||||||
|
},
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.close()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ export default {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.floating-btn {
|
.floating-btn {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin: auto 1em 1em auto;
|
margin: auto 1em 1em auto;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<form @submit.prevent="onConfirm">
|
<form @submit.prevent="onConfirm">
|
||||||
<div class="dialog-content">
|
<div class="dialog-content">
|
||||||
<slot />
|
<slot />
|
||||||
<input type="text" ref="input" />
|
<input type="text" ref="input" v-model="value_" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
|
@ -38,26 +38,82 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: "Cancel",
|
default: "Cancel",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
value_: "",
|
||||||
|
visible_: false,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
onConfirm() {
|
onConfirm() {
|
||||||
this.$emit('input', this.$refs.input.value)
|
if (this.value_?.trim()?.length) {
|
||||||
|
this.$emit('input', this.value_)
|
||||||
|
}
|
||||||
|
|
||||||
this.close()
|
this.close()
|
||||||
},
|
},
|
||||||
|
|
||||||
show() {
|
open() {
|
||||||
|
if (this.visible_)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.value_ = this.value
|
||||||
this.$refs.modal.show()
|
this.$refs.modal.show()
|
||||||
|
this.visible_ = true
|
||||||
|
this.focus()
|
||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
if (!this.visible_)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.value_ = ""
|
||||||
this.$refs.modal.hide()
|
this.$refs.modal.hide()
|
||||||
|
this.visible_ = false
|
||||||
|
},
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.open()
|
||||||
|
},
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.close()
|
||||||
|
},
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.input.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
visible(val) {
|
||||||
|
if (val) {
|
||||||
|
this.open()
|
||||||
|
} else {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.visible_ = this.visible
|
||||||
|
this.value_ = this.value || ""
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.input.value = ""
|
|
||||||
this.$refs.input.focus()
|
this.$refs.input.focus()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
@ -67,6 +123,8 @@ export default {
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
:deep(.modal) {
|
:deep(.modal) {
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,24 +23,42 @@
|
||||||
<component
|
<component
|
||||||
:is="mediaProvider"
|
:is="mediaProvider"
|
||||||
:filter="filter"
|
:filter="filter"
|
||||||
@back="mediaProvider = null"
|
:selected-playlist="selectedPlaylist"
|
||||||
|
:selected-channel="selectedChannel"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@back="back"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
@path-change="$emit('path-change', $event)"
|
@path-change="$emit('path-change', $event)"
|
||||||
@play="$emit('play', $event)" />
|
@play="$emit('play', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { defineAsyncComponent, shallowRef } from "vue";
|
import { defineAsyncComponent, ref } from "vue";
|
||||||
import Browser from "@/components/File/Browser";
|
import Browser from "@/components/File/Browser";
|
||||||
import Loading from "@/components/Loading";
|
import Loading from "@/components/Loading";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
import providersMetadata from "./Providers/meta.json";
|
import providersMetadata from "./Providers/meta.json";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['path-change', 'play'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'back',
|
||||||
|
'create-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'path-change',
|
||||||
|
'play',
|
||||||
|
'remove-from-playlist',
|
||||||
|
'remove-playlist',
|
||||||
|
'rename-playlist',
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Browser,
|
Browser,
|
||||||
Loading,
|
Loading,
|
||||||
|
@ -51,6 +69,14 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
selectedPlaylist: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedChannel: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -62,9 +88,24 @@ 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 = ref(
|
||||||
defineAsyncComponent(
|
defineAsyncComponent(
|
||||||
() => import(`@/components/panels/Media/Providers/${type}`)
|
() => import(`@/components/panels/Media/Providers/${type}`)
|
||||||
)
|
)
|
||||||
|
@ -83,10 +124,81 @@ export default {
|
||||||
if (config.youtube)
|
if (config.youtube)
|
||||||
this.registerMediaProvider('YouTube')
|
this.registerMediaProvider('YouTube')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onPlaylistChange() {
|
||||||
|
if (!this.selectedPlaylist)
|
||||||
|
return
|
||||||
|
|
||||||
|
const playlistType = this.selectedPlaylist.type?.toLowerCase()
|
||||||
|
const playlistMediaProvider = this.mediaProvidersLookup[playlistType]
|
||||||
|
|
||||||
|
if (playlistMediaProvider) {
|
||||||
|
this.mediaProvider = this.mediaProviders[playlistMediaProvider]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onChannelChange() {
|
||||||
|
if (!this.selectedChannel)
|
||||||
|
return
|
||||||
|
|
||||||
|
const channelType = this.selectedChannel.type?.toLowerCase()
|
||||||
|
const channelMediaProvider = this.mediaProvidersLookup[channelType]
|
||||||
|
|
||||||
|
if (channelMediaProvider) {
|
||||||
|
this.mediaProvider = this.mediaProviders[channelMediaProvider]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateView() {
|
||||||
|
if (this.getUrlArgs().provider?.length) {
|
||||||
|
const provider = this.getUrlArgs().provider
|
||||||
|
const providerName = this.mediaProvidersLookup[provider.toLowerCase()]
|
||||||
|
|
||||||
|
if (!providerName?.length)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.mediaProvider = this.mediaProviders[providerName]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedPlaylist)
|
||||||
|
this.onPlaylistChange()
|
||||||
|
else if (this.selectedChannel)
|
||||||
|
this.onChannelChange()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
watch: {
|
||||||
this.refreshMediaProviders()
|
mediaProvider(provider) {
|
||||||
|
if (!provider) {
|
||||||
|
this.setUrlArgs({provider: null})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerName = Object.entries(this.mediaProviders)
|
||||||
|
.filter((pair) => pair[1] === provider)?.[0]?.[0]?.toLowerCase()
|
||||||
|
|
||||||
|
if (!providerName?.length)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.setUrlArgs({provider: providerName})
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedPlaylist() {
|
||||||
|
this.onPlaylistChange()
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedChannel() {
|
||||||
|
this.onChannelChange()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
await this.refreshMediaProviders()
|
||||||
|
this.updateView()
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
this.setUrlArgs({provider: null})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,321 @@
|
||||||
|
<template>
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
|
||||||
|
<div class="media-downloads fade-in" v-else>
|
||||||
|
<div class="no-content" v-if="!Object.keys(downloads).length">No media downloads in progress</div>
|
||||||
|
|
||||||
|
<div class="no-content" v-else-if="!Object.keys(filteredDownloads).length">No media downloads match the filter</div>
|
||||||
|
|
||||||
|
<div class="items" v-else>
|
||||||
|
<div class="row item"
|
||||||
|
:class="{selected: selectedItem === i}"
|
||||||
|
:key="i"
|
||||||
|
v-for="(media, i) in filteredDownloads"
|
||||||
|
@click="selectedItem = i"
|
||||||
|
>
|
||||||
|
<div class="col-8 left side">
|
||||||
|
<i class="icon fa" :class="{
|
||||||
|
'fa-check': media.state.toLowerCase() === 'completed',
|
||||||
|
'fa-play': media.state.toLowerCase() === 'downloading',
|
||||||
|
'fa-pause': media.state.toLowerCase() === 'paused',
|
||||||
|
'fa-times': media.state.toLowerCase() === 'cancelled',
|
||||||
|
'fa-stop': media.state.toLowerCase() === 'idle',
|
||||||
|
'fa-hourglass-half': media.state.toLowerCase() === 'started',
|
||||||
|
}" />
|
||||||
|
<div class="title" v-text="media.path || media.url" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-2 right side">
|
||||||
|
<span v-text="displayProgress[i]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-2 right side">
|
||||||
|
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" @click="selectedItem = i">
|
||||||
|
<DropdownItem icon-class="fa fa-play" text="Play"
|
||||||
|
@click="$emit('play', {url: `file:///${media.path}`})"
|
||||||
|
v-if="media.state.toLowerCase() === 'completed'" />
|
||||||
|
<DropdownItem icon-class="fa fa-pause" text="Pause download" @click="pause(media)"
|
||||||
|
v-if="media.state.toLowerCase() === 'downloading' || media.state.toLowerCase() === 'started'" />
|
||||||
|
<DropdownItem icon-class="fa fa-rotate-left" text="Resume download" @click="resume(media)"
|
||||||
|
v-if="media.state.toLowerCase() === 'paused'" />
|
||||||
|
<DropdownItem icon-class="fa fa-eraser" text="Clear from queue" @click="clear(media)"
|
||||||
|
v-if="media.state.toLowerCase() === 'completed'" />
|
||||||
|
<DropdownItem icon-class="fa fa-stop" text="Cancel" @click="cancel(media)"
|
||||||
|
v-if="media.state.toLowerCase() !== 'completed' && media.state.toLowerCase() !== 'cancelled'" />
|
||||||
|
<DropdownItem icon-class="fa fa-trash" text="Remove file" @click="onDeleteSelected(media)"
|
||||||
|
v-if="media.state.toLowerCase() === 'completed' || media.state.toLowerCase() === 'cancelled'" />
|
||||||
|
<DropdownItem icon-class="fa fa-info" text="Media info" @click="$refs.mediaInfo.isVisible = true" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal ref="mediaInfo" title="Media info" width="80%">
|
||||||
|
<div class="modal-body media-info" v-if="selectedItem != null && downloads[selectedItem]">
|
||||||
|
<div class="row" v-if="downloads[selectedItem].name">
|
||||||
|
<div class="attr">Path</div>
|
||||||
|
<div class="value" v-text="downloads[selectedItem].path" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].url">
|
||||||
|
<div class="attr">Remote URL</div>
|
||||||
|
<div class="value">
|
||||||
|
<a :href="downloads[selectedItem].url" target="_blank" v-text="downloads[selectedItem].url" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].path">
|
||||||
|
<div class="attr">Local URL</div>
|
||||||
|
<div class="value">
|
||||||
|
<a :href="localURL(downloads[selectedItem])"
|
||||||
|
target="_blank" v-text="downloads[selectedItem].path" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].state">
|
||||||
|
<div class="attr">State</div>
|
||||||
|
<div class="value" v-text="downloads[selectedItem].state" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].progress != null">
|
||||||
|
<div class="attr">Progress</div>
|
||||||
|
<div class="value" v-text="displayProgress[selectedItem]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].size != null">
|
||||||
|
<div class="attr">Size</div>
|
||||||
|
<div class="value" v-text="convertSize(downloads[selectedItem].size)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].started_at">
|
||||||
|
<div class="attr">Started</div>
|
||||||
|
<div class="value" v-text="formatDateTime(downloads[selectedItem].started_at)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="downloads[selectedItem].ended_at">
|
||||||
|
<div class="attr">Ended</div>
|
||||||
|
<div class="value" v-text="formatDateTime(downloads[selectedItem].ended_at)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
ref="deleteConfirmDialog"
|
||||||
|
title="Delete file"
|
||||||
|
@input="rm"
|
||||||
|
@close="mediaToDelete = null"
|
||||||
|
>
|
||||||
|
Are you sure you want to delete the downloaded file?
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||||
|
import Loading from "@/components/Loading";
|
||||||
|
import Utils from "@/Utils";
|
||||||
|
import MediaUtils from "@/components/Media/Utils.vue"
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import Dropdown from "@/components/elements/Dropdown";
|
||||||
|
import DropdownItem from "@/components/elements/DropdownItem";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [Utils, MediaUtils],
|
||||||
|
emits: [
|
||||||
|
'play',
|
||||||
|
'refresh',
|
||||||
|
],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
ConfirmDialog,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
Loading,
|
||||||
|
Modal,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
downloads: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
|
||||||
|
pluginName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
filter: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
selectedItem: null,
|
||||||
|
mediaToDelete: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
relativeFiles() {
|
||||||
|
if (this.selectedItem == null || !this.downloads[this.selectedItem]?.files?.length)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return this.downloads[this.selectedItem].files.map((file) => file.split('/').pop())
|
||||||
|
},
|
||||||
|
|
||||||
|
displayProgress() {
|
||||||
|
return Object.values(this.downloads).reduce((acc, value) => {
|
||||||
|
let progress = this.round(value.progress, 2)
|
||||||
|
let percent = progress != null ? `${progress}%` : 'N/A'
|
||||||
|
if (value.state.toLowerCase() === 'completed')
|
||||||
|
percent = '100%'
|
||||||
|
|
||||||
|
acc[value.path] = percent
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
|
||||||
|
filteredDownloads() {
|
||||||
|
const filter = (this.filter || '').trim().toLowerCase()
|
||||||
|
let downloads = Object.values(this.downloads)
|
||||||
|
if (filter?.length) {
|
||||||
|
downloads = downloads.filter((download) => {
|
||||||
|
return download.path.toLowerCase().includes(filter) ||
|
||||||
|
download.url.toLowerCase().includes(filter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloads.reduce((acc, download) => {
|
||||||
|
acc[download.path] = download
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async run(action, media) {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.request(
|
||||||
|
`${this.pluginName}.${action}`,
|
||||||
|
{path: media.path}
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pause(media) {
|
||||||
|
await this.run('pause_download', media)
|
||||||
|
},
|
||||||
|
|
||||||
|
async resume(media) {
|
||||||
|
await this.run('resume_download', media)
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear(media) {
|
||||||
|
await this.run('clear_downloads', media)
|
||||||
|
if (this.downloads[media.path])
|
||||||
|
delete this.downloads[media.path]
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel(media) {
|
||||||
|
await this.run('cancel_download', media)
|
||||||
|
},
|
||||||
|
|
||||||
|
async rm() {
|
||||||
|
const media = this.mediaToDelete
|
||||||
|
if (!media)
|
||||||
|
return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.request('file.unlink', {file: media.path})
|
||||||
|
} finally {
|
||||||
|
await this.clear(media)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
localURL(media) {
|
||||||
|
return `${window.location.origin}/file?path=${encodeURIComponent(media.path)}`
|
||||||
|
},
|
||||||
|
|
||||||
|
onDeleteSelected(media) {
|
||||||
|
this.mediaToDelete = media
|
||||||
|
this.$refs.deleteConfirmDialog.show()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "src/style/items";
|
||||||
|
|
||||||
|
.media-downloads {
|
||||||
|
height: 100%;
|
||||||
|
background: $background-color;
|
||||||
|
|
||||||
|
.no-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.modal-body) {
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: $default-border;
|
||||||
|
padding: .5em .25em;
|
||||||
|
border-radius: .5em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $hover-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attr {
|
||||||
|
@extend .col-3;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
@extend .col-9;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: right;
|
||||||
|
|
||||||
|
&.nowrap {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.modal-body) {
|
||||||
|
.dropdown-container {
|
||||||
|
.row {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $default-hover-fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -22,6 +22,14 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-s-8 col-m-7 left side" v-else-if="selectedView === 'downloads'">
|
||||||
|
<form @submit.prevent="$emit('filter-downloads', downloadFilter)">
|
||||||
|
<label class="search-box">
|
||||||
|
<input type="search" placeholder="Filter" v-model="downloadFilter">
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-s-8 col-m-7 left side" v-else-if="selectedView === 'browser'">
|
<div class="col-s-8 col-m-7 left side" v-else-if="selectedView === 'browser'">
|
||||||
<label class="search-box">
|
<label class="search-box">
|
||||||
<input type="search" placeholder="Filter" :value="browserFilter" @change="$emit('filter', $event.target.value)"
|
<input type="search" placeholder="Filter" :value="browserFilter" @change="$emit('filter', $event.target.value)"
|
||||||
|
@ -65,6 +73,7 @@ export default {
|
||||||
components: {Players},
|
components: {Players},
|
||||||
emits: [
|
emits: [
|
||||||
'filter',
|
'filter',
|
||||||
|
'filter-downloads',
|
||||||
'play-url',
|
'play-url',
|
||||||
'player-status',
|
'player-status',
|
||||||
'search',
|
'search',
|
||||||
|
@ -119,6 +128,7 @@ export default {
|
||||||
filterVisible: false,
|
filterVisible: false,
|
||||||
query: '',
|
query: '',
|
||||||
torrentURL: '',
|
torrentURL: '',
|
||||||
|
downloadFilter: '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<div class="media-plugin fade-in">
|
<div class="media-plugin fade-in">
|
||||||
<Loading v-if="loading" />
|
<MediaView :plugin-name="pluginName"
|
||||||
|
:status="selectedPlayer?.status || {}"
|
||||||
<MediaView :plugin-name="pluginName" :status="selectedPlayer?.status || {}" :track="selectedPlayer?.status || {}"
|
:track="selectedPlayer?.status || {}"
|
||||||
:buttons="mediaButtons" @play="pause" @pause="pause" @stop="stop" @set-volume="setVolume"
|
:buttons="mediaButtons"
|
||||||
@seek="seek" @search="search" @mute="toggleMute" @unmute="toggleMute">
|
@play="pause"
|
||||||
|
@pause="pause"
|
||||||
|
@stop="stop"
|
||||||
|
@set-volume="setVolume"
|
||||||
|
@seek="seek"
|
||||||
|
@search="search"
|
||||||
|
@mute="toggleMute"
|
||||||
|
@unmute="toggleMute"
|
||||||
|
>
|
||||||
<main>
|
<main>
|
||||||
<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"
|
:download-icon-class="downloadIconClass"
|
||||||
@toggle="forceShowNav = !forceShowNav" />
|
@input="setView"
|
||||||
|
@toggle="forceShowNav = !forceShowNav"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="view-container">
|
<div class="view-container">
|
||||||
|
@ -22,6 +32,7 @@
|
||||||
:selected-item="selectedItem"
|
:selected-item="selectedItem"
|
||||||
:selected-subtitles="selectedSubtitles"
|
:selected-subtitles="selectedSubtitles"
|
||||||
:browser-filter="browserFilter"
|
:browser-filter="browserFilter"
|
||||||
|
:downloads-filter="downloadsFilter"
|
||||||
:show-nav-button="!forceShowNav"
|
:show-nav-button="!forceShowNav"
|
||||||
ref="header"
|
ref="header"
|
||||||
@search="search"
|
@search="search"
|
||||||
|
@ -31,30 +42,52 @@
|
||||||
@show-subtitles="showSubtitlesModal = !showSubtitlesModal"
|
@show-subtitles="showSubtitlesModal = !showSubtitlesModal"
|
||||||
@play-url="showPlayUrlModal"
|
@play-url="showPlayUrlModal"
|
||||||
@filter="browserFilter = $event"
|
@filter="browserFilter = $event"
|
||||||
|
@filter-downloads="downloadsFilter = $event"
|
||||||
@toggle-nav="forceShowNav = !forceShowNav"
|
@toggle-nav="forceShowNav = !forceShowNav"
|
||||||
@source-toggle="sources[$event] = !sources[$event]" />
|
@source-toggle="sources[$event] = !sources[$event]"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
|
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
|
||||||
<Results :results="results"
|
<Results :results="results"
|
||||||
:selected-result="selectedResult"
|
:selected-result="selectedResult"
|
||||||
:sources="sources"
|
:sources="sources"
|
||||||
|
:plugin-name="pluginName"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:filter="browserFilter"
|
:filter="browserFilter"
|
||||||
|
@add-to-playlist="addToPlaylistItem = $event"
|
||||||
|
@open-channel="selectChannelFromItem"
|
||||||
@select="onResultSelect($event)"
|
@select="onResultSelect($event)"
|
||||||
@play="play"
|
@play="play"
|
||||||
@view="view"
|
@view="view"
|
||||||
@download="download"
|
@download="download"
|
||||||
v-if="selectedView === 'search'" />
|
@download-audio="downloadAudio"
|
||||||
|
v-if="selectedView === 'search'"
|
||||||
|
/>
|
||||||
|
|
||||||
<Transfers :plugin-name="torrentPlugin"
|
<TorrentTransfers :plugin-name="torrentPlugin"
|
||||||
:is-media="true"
|
:is-media="true"
|
||||||
@play="play"
|
@play="play"
|
||||||
v-else-if="selectedView === 'torrents'" />
|
v-else-if="selectedView === 'torrents'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MediaDownloads :plugin-name="pluginName"
|
||||||
|
:downloads="downloads"
|
||||||
|
:filter="downloadsFilter"
|
||||||
|
@play="play"
|
||||||
|
v-else-if="selectedView === 'downloads'"
|
||||||
|
/>
|
||||||
|
|
||||||
<Browser :filter="browserFilter"
|
<Browser :filter="browserFilter"
|
||||||
|
:selected-playlist="selectedPlaylist"
|
||||||
|
:selected-channel="selectedChannel"
|
||||||
|
@add-to-playlist="addToPlaylistItem = $event"
|
||||||
|
@back="selectedResult = null"
|
||||||
|
@download="download"
|
||||||
|
@download-audio="downloadAudio"
|
||||||
@path-change="browserFilter = ''"
|
@path-change="browserFilter = ''"
|
||||||
@play="play($event)"
|
@play="play($event)"
|
||||||
v-else-if="selectedView === 'browser'" />
|
v-else-if="selectedView === 'browser'"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -76,23 +109,34 @@
|
||||||
<UrlPlayer :value="urlPlay" @input="urlPlay = $event.target.value" @play="playUrl($event)" />
|
<UrlPlayer :value="urlPlay" @input="urlPlay = $event.target.value" @play="playUrl($event)" />
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="add-to-playlist-container" v-if="addToPlaylistItem">
|
||||||
|
<Modal title="Add to playlist" :visible="addToPlaylistItem != null" @close="addToPlaylistItem = null">
|
||||||
|
<PlaylistAdder
|
||||||
|
:item="addToPlaylistItem"
|
||||||
|
@done="addToPlaylistItem = null"
|
||||||
|
@close="addToPlaylistItem = null"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Loading from "@/components/Loading";
|
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import Utils from "@/Utils";
|
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 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";
|
||||||
import Nav from "@/components/panels/Media/Nav";
|
import Nav from "@/components/panels/Media/Nav";
|
||||||
|
import PlaylistAdder from "@/components/panels/Media/PlaylistAdder";
|
||||||
import Results from "@/components/panels/Media/Results";
|
import Results from "@/components/panels/Media/Results";
|
||||||
import Subtitles from "@/components/panels/Media/Subtitles";
|
import Subtitles from "@/components/panels/Media/Subtitles";
|
||||||
import Transfers from "@/components/panels/Torrent/Transfers";
|
import TorrentTransfers from "@/components/panels/Torrent/Transfers";
|
||||||
import UrlPlayer from "@/components/panels/Media/UrlPlayer";
|
import UrlPlayer from "@/components/panels/Media/UrlPlayer";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -101,13 +145,14 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
Browser,
|
Browser,
|
||||||
Header,
|
Header,
|
||||||
Loading,
|
MediaDownloads,
|
||||||
MediaView,
|
MediaView,
|
||||||
Modal,
|
Modal,
|
||||||
Nav,
|
Nav,
|
||||||
|
PlaylistAdder,
|
||||||
Results,
|
Results,
|
||||||
Subtitles,
|
Subtitles,
|
||||||
Transfers,
|
TorrentTransfers,
|
||||||
UrlPlayer,
|
UrlPlayer,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -137,11 +182,14 @@ export default {
|
||||||
selectedPlayer: null,
|
selectedPlayer: null,
|
||||||
selectedView: 'search',
|
selectedView: 'search',
|
||||||
selectedSubtitles: null,
|
selectedSubtitles: null,
|
||||||
|
prevSelectedView: null,
|
||||||
showSubtitlesModal: false,
|
showSubtitlesModal: false,
|
||||||
forceShowNav: false,
|
forceShowNav: false,
|
||||||
awaitingPlayTorrent: null,
|
awaitingPlayTorrent: null,
|
||||||
urlPlay: null,
|
urlPlay: null,
|
||||||
browserFilter: null,
|
browserFilter: null,
|
||||||
|
downloadsFilter: null,
|
||||||
|
addToPlaylistItem: null,
|
||||||
torrentPlugin: null,
|
torrentPlugin: null,
|
||||||
torrentPlugins: [
|
torrentPlugins: [
|
||||||
'torrent',
|
'torrent',
|
||||||
|
@ -153,6 +201,8 @@ export default {
|
||||||
'youtube': true,
|
'youtube': true,
|
||||||
'torrent': true,
|
'torrent': true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
downloads: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -182,6 +232,50 @@ 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]
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedChannel() {
|
||||||
|
if (this.selectedResult == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const selectedItem = this.results[this.selectedResult]
|
||||||
|
if (selectedItem?.item_type !== 'channel')
|
||||||
|
return null
|
||||||
|
|
||||||
|
return this.results[this.selectedResult]
|
||||||
|
},
|
||||||
|
|
||||||
|
hasPendingDownloads() {
|
||||||
|
return Object.values(this.downloads).some((download) => {
|
||||||
|
return !['completed', 'cancelled'].includes(download.state.toLowerCase())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
allDownloadsCompleted() {
|
||||||
|
return Object.values(this.downloads).length && Object.values(this.downloads).every((download) => {
|
||||||
|
return ['completed', 'cancelled'].includes(download.state.toLowerCase())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadIconClass() {
|
||||||
|
if (this.hasPendingDownloads)
|
||||||
|
return 'glow loop'
|
||||||
|
|
||||||
|
if (this.allDownloadsCompleted)
|
||||||
|
return 'completed'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -252,12 +346,19 @@ export default {
|
||||||
window.open(ret.url, '_blank')
|
window.open(ret.url, '_blank')
|
||||||
},
|
},
|
||||||
|
|
||||||
async download(item) {
|
async download(item, args) {
|
||||||
if (item?.type === 'torrent') {
|
switch (item.type) {
|
||||||
await this.downloadTorrent(item)
|
case 'torrent':
|
||||||
|
return await this.downloadTorrent(item, args)
|
||||||
|
case 'youtube':
|
||||||
|
return await this.downloadYoutube(item, args)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async downloadAudio(item) {
|
||||||
|
await this.download(item, {onlyAudio: true})
|
||||||
|
},
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
this.selectedPlayer.status = await this.selectedPlayer.component.status(this.selectedPlayer)
|
this.selectedPlayer.status = await this.selectedPlayer.component.status(this.selectedPlayer)
|
||||||
},
|
},
|
||||||
|
@ -347,7 +448,35 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.request(`${torrentPlugin}.download`, {torrent: item?.url || item})
|
if (!item?.url) {
|
||||||
|
this.notify({
|
||||||
|
text: 'No torrent URL available',
|
||||||
|
error: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.request(`${torrentPlugin}.download`, {torrent: item.url || item})
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadYoutube(item, args) {
|
||||||
|
if (!item?.url) {
|
||||||
|
this.notify({
|
||||||
|
text: 'No YouTube URL available',
|
||||||
|
error: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestArgs = {url: item.url}
|
||||||
|
const onlyAudio = !!args?.onlyAudio
|
||||||
|
if (onlyAudio) {
|
||||||
|
requestArgs.only_audio = true
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.request(`${this.pluginName}.download`, requestArgs)
|
||||||
},
|
},
|
||||||
|
|
||||||
async selectSubtitles(item) {
|
async selectSubtitles(item) {
|
||||||
|
@ -382,6 +511,21 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
this.selectedResult = null
|
this.selectedResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedItem = this.results[this.selectedResult]
|
||||||
|
if (this.selectedResult != null && (selectedItem?.item_type === 'playlist' || selectedItem?.item_type === 'channel')) {
|
||||||
|
this.onBrowserItemSelect()
|
||||||
|
} else {
|
||||||
|
this.selectedView = this.prevSelectedView || 'search'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onBrowserItemSelect() {
|
||||||
|
if (this.prevSelectedView != this.selectedView) {
|
||||||
|
this.prevSelectedView = this.selectedView
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedView = 'browser'
|
||||||
},
|
},
|
||||||
|
|
||||||
showPlayUrlModal() {
|
showPlayUrlModal() {
|
||||||
|
@ -402,45 +546,209 @@ export default {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async refreshDownloads() {
|
||||||
|
this.downloads = (await this.request(`${this.pluginName}.get_downloads`)).reduce((acc, download) => {
|
||||||
|
acc[download.path] = download
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
|
||||||
|
setView(title) {
|
||||||
|
this.selectedView = title
|
||||||
|
if (title === 'search') {
|
||||||
|
this.selectedResult = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateView() {
|
||||||
|
const args = this.getUrlArgs()
|
||||||
|
if (args.view) {
|
||||||
|
this.selectedView = args.view
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.player && this.players?.length) {
|
||||||
|
this.selectedPlayer = this.players.find((player) => player.name === args.player)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.subtitles) {
|
||||||
|
this.selectedSubtitles = args.subtitles
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadStarted(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
this.notify({
|
||||||
|
title: 'Media download started',
|
||||||
|
html: `Saving <b>${event.resource}</b> to <b>${event.path}</b>`,
|
||||||
|
image: {
|
||||||
|
iconClass: 'fa fa-download',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadCompleted(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
this.downloads[event.path].progress = 100
|
||||||
|
|
||||||
|
this.notify({
|
||||||
|
title: 'Media download completed',
|
||||||
|
html: `Saved <b>${event.resource}</b> to <b>${event.path}</b>`,
|
||||||
|
image: {
|
||||||
|
iconClass: 'fa fa-check',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadError(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
this.notify({
|
||||||
|
title: 'Media download error',
|
||||||
|
html: `Error downloading ${event.resource}: <b>${event.error}</b>`,
|
||||||
|
error: true,
|
||||||
|
image: {
|
||||||
|
iconClass: 'fa fa-exclamation-triangle',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadCancelled(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
this.notify({
|
||||||
|
title: 'Media download cancelled',
|
||||||
|
html: `Cancelled download of <b>${event.resource}</b>`,
|
||||||
|
image: {
|
||||||
|
iconClass: 'fa fa-times',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadPaused(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
this.notify({
|
||||||
|
title: 'Media download paused',
|
||||||
|
html: `Paused download of <b>${event.resource}</b>`,
|
||||||
|
image: {
|
||||||
|
iconClass: 'fa fa-pause',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadResumed(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
this.notify({
|
||||||
|
title: 'Media download resumed',
|
||||||
|
html: `Resumed download of <b>${event.resource}</b>`,
|
||||||
|
image: {
|
||||||
|
iconClass: 'fa fa-play',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadProgress(event) {
|
||||||
|
this.downloads[event.path] = event
|
||||||
|
},
|
||||||
|
|
||||||
|
onDownloadClear(event) {
|
||||||
|
if (event.path in this.downloads)
|
||||||
|
delete this.downloads[event.path]
|
||||||
|
},
|
||||||
|
|
||||||
|
selectChannelFromItem(item) {
|
||||||
|
const mediaProvider = item?.type
|
||||||
|
const channelId = (
|
||||||
|
item?.channel_id ||
|
||||||
|
item?.channel?.id ||
|
||||||
|
item?.channel_url.split('/').pop()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!mediaProvider && channelId == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.setUrlArgs({
|
||||||
|
provider: mediaProvider,
|
||||||
|
section: 'subscriptions',
|
||||||
|
channel: channelId,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.selectedView = 'browser'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
watch: {
|
||||||
this.$watch(() => this.selectedPlayer, (player) => {
|
selectedPlayer(player) {
|
||||||
|
this.setUrlArgs({player: player?.name})
|
||||||
if (player)
|
if (player)
|
||||||
this.refresh()
|
this.refresh()
|
||||||
})
|
},
|
||||||
|
|
||||||
this.$watch(() => this.selectedSubtitles, (subs) => {
|
selectedSubtitles(subs) {
|
||||||
|
this.setUrlArgs({subtitles: this.selectedSubtitles})
|
||||||
if (new Set(['play', 'pause']).has(this.selectedPlayer?.status?.state)) {
|
if (new Set(['play', 'pause']).has(this.selectedPlayer?.status?.state)) {
|
||||||
if (subs)
|
if (subs)
|
||||||
this.selectedPlayer.component.addSubtitles(subs)
|
this.selectedPlayer.component.addSubtitles(subs)
|
||||||
else
|
else
|
||||||
this.selectedPlayer.component.removeSubtitles()
|
this.selectedPlayer.component.removeSubtitles()
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
|
||||||
|
selectedView() {
|
||||||
|
this.setUrlArgs({view: this.selectedView})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
this.torrentPlugin = this.getTorrentPlugin()
|
this.torrentPlugin = this.getTorrentPlugin()
|
||||||
this.subscribe(this.onTorrentQueued,'notify-on-torrent-queued',
|
this.subscribe(this.onTorrentQueued,'on-torrent-queued',
|
||||||
'platypush.message.event.torrent.TorrentQueuedEvent')
|
'platypush.message.event.torrent.TorrentQueuedEvent')
|
||||||
this.subscribe(this.onTorrentMetadata,'on-torrent-metadata',
|
this.subscribe(this.onTorrentMetadata,'on-torrent-metadata',
|
||||||
'platypush.message.event.torrent.TorrentDownloadedMetadataEvent')
|
'platypush.message.event.torrent.TorrentDownloadedMetadataEvent')
|
||||||
this.subscribe(this.onTorrentDownloadStart,'notify-on-torrent-download-start',
|
this.subscribe(this.onTorrentDownloadStart,'on-torrent-download-start',
|
||||||
'platypush.message.event.torrent.TorrentDownloadStartEvent')
|
'platypush.message.event.torrent.TorrentDownloadStartEvent')
|
||||||
this.subscribe(this.onTorrentDownloadCompleted,'notify-on-torrent-download-completed',
|
this.subscribe(this.onTorrentDownloadCompleted,'on-torrent-download-completed',
|
||||||
'platypush.message.event.torrent.TorrentDownloadCompletedEvent')
|
'platypush.message.event.torrent.TorrentDownloadCompletedEvent')
|
||||||
|
|
||||||
|
this.subscribe(this.onDownloadStarted,'on-download-started',
|
||||||
|
'platypush.message.event.media.MediaDownloadStartedEvent')
|
||||||
|
this.subscribe(this.onDownloadCompleted,'on-download-completed',
|
||||||
|
'platypush.message.event.media.MediaDownloadCompletedEvent')
|
||||||
|
this.subscribe(this.onDownloadError,'on-download-error',
|
||||||
|
'platypush.message.event.media.MediaDownloadErrorEvent')
|
||||||
|
this.subscribe(this.onDownloadCancelled,'on-download-cancelled',
|
||||||
|
'platypush.message.event.media.MediaDownloadCancelledEvent')
|
||||||
|
this.subscribe(this.onDownloadPaused,'on-download-paused',
|
||||||
|
'platypush.message.event.media.MediaDownloadPausedEvent')
|
||||||
|
this.subscribe(this.onDownloadResumed,'on-download-resumed',
|
||||||
|
'platypush.message.event.media.MediaDownloadResumedEvent')
|
||||||
|
this.subscribe(this.onDownloadProgress,'on-download-progress',
|
||||||
|
'platypush.message.event.media.MediaDownloadProgressEvent')
|
||||||
|
this.subscribe(this.onDownloadClear,'on-download-clear',
|
||||||
|
'platypush.message.event.media.MediaDownloadClearEvent')
|
||||||
|
|
||||||
if ('media.plex' in this.$root.config)
|
if ('media.plex' in this.$root.config)
|
||||||
this.sources.plex = true
|
this.sources.plex = true
|
||||||
|
|
||||||
if ('media.jellyfin' in this.$root.config)
|
if ('media.jellyfin' in this.$root.config)
|
||||||
this.sources.jellyfin = true
|
this.sources.jellyfin = true
|
||||||
|
|
||||||
|
await this.refreshDownloads()
|
||||||
|
this.updateView()
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.unsubscribe('notify-on-torrent-queued')
|
this.unsubscribe('on-torrent-queued')
|
||||||
this.unsubscribe('on-torrent-metadata')
|
this.unsubscribe('on-torrent-metadata')
|
||||||
this.unsubscribe('notify-on-torrent-download-start')
|
this.unsubscribe('on-torrent-download-start')
|
||||||
this.unsubscribe('notify-on-torrent-download-completed')
|
this.unsubscribe('on-torrent-download-completed')
|
||||||
|
|
||||||
|
this.unsubscribe('on-download-started')
|
||||||
|
this.unsubscribe('on-download-completed')
|
||||||
|
this.unsubscribe('on-download-error')
|
||||||
|
this.unsubscribe('on-download-cancelled')
|
||||||
|
this.unsubscribe('on-download-paused')
|
||||||
|
this.unsubscribe('on-download-resumed')
|
||||||
|
this.unsubscribe('on-download-progress')
|
||||||
|
this.unsubscribe('on-download-clear')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -490,4 +798,10 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.add-to-playlist-container) {
|
||||||
|
.body {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<div class="row header">
|
<div class="row header">
|
||||||
<div class="image-container">
|
<div class="image-container">
|
||||||
<MediaImage :item="item" @play="$emit('play')" />
|
<MediaImage :item="item" @play="$emit('play')" @select="$emit('select')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title">
|
<div class="title">
|
||||||
|
@ -16,6 +16,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row direct-url" v-if="directUrl">
|
||||||
|
<div class="left side">Direct URL</div>
|
||||||
|
<div class="right side">
|
||||||
|
<a :href="directUrl" title="Direct URL" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt" />
|
||||||
|
</a>
|
||||||
|
<button @click="copyToClipboard(directUrl)" title="Copy URL to clipboard">
|
||||||
|
<i class="fas fa-clipboard" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="item?.series">
|
<div class="row" v-if="item?.series">
|
||||||
<div class="left side">TV Series</div>
|
<div class="left side">TV Series</div>
|
||||||
<div class="right side" v-text="item.series" />
|
<div class="right side" v-text="item.series" />
|
||||||
|
@ -169,12 +181,18 @@ export default {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
}
|
},
|
||||||
|
|
||||||
|
pluginName: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
typeIcons: Icons,
|
typeIcons: Icons,
|
||||||
|
loadingUrl: false,
|
||||||
|
youtubeUrl: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -209,6 +227,15 @@ export default {
|
||||||
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
directUrl() {
|
||||||
|
if (this.item?.type === 'file' && this.item?.url) {
|
||||||
|
const path = this.item.url.replace(/^file:\/\//, '')
|
||||||
|
return window.location.origin + '/file?path=' + encodeURIComponent(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -280,6 +307,24 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.direct-url {
|
||||||
|
.right.side {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 1em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $default-hover-fg;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -5,18 +5,24 @@
|
||||||
@click.right.prevent="$refs.dropdown.toggle()"
|
@click.right.prevent="$refs.dropdown.toggle()"
|
||||||
v-if="!hidden">
|
v-if="!hidden">
|
||||||
<div class="thumbnail">
|
<div class="thumbnail">
|
||||||
<MediaImage :item="item" @play="$emit('play')" />
|
<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" @click="$emit('select')" />
|
<div class="col-11 left side" v-text="item.title || item.name" @click="$emit('select')" />
|
||||||
<div class="col-1 right side">
|
<div class="col-1 right side">
|
||||||
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown">
|
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown">
|
||||||
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play')"
|
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play')"
|
||||||
v-if="item.type !== 'torrent'" />
|
v-if="item.type !== 'torrent'" />
|
||||||
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
|
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
|
||||||
v-if="item.type === 'torrent'" />
|
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" @click="$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" @click="$emit('add-to-playlist')"
|
||||||
|
v-if="item.type === 'youtube'" />
|
||||||
|
<DropdownItem icon-class="fa fa-trash" text="Remove from playlist" @click="$emit('remove-from-playlist')"
|
||||||
|
v-if="item.type === 'youtube' && playlist?.length" />
|
||||||
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view')"
|
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view')"
|
||||||
v-if="item.type === 'file'" />
|
v-if="item.type === 'file'" />
|
||||||
<DropdownItem icon-class="fa fa-info-circle" text="Info" @click="$emit('select')" />
|
<DropdownItem icon-class="fa fa-info-circle" text="Info" @click="$emit('select')" />
|
||||||
|
@ -25,7 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row subtitle" v-if="item.channel">
|
<div class="row subtitle" v-if="item.channel">
|
||||||
<a class="channel" :href="item.channel_url" target="_blank">
|
<a class="channel" href="#" target="_blank" @click.prevent="$emit('open-channel')">
|
||||||
<img :src="item.channel_image" class="channel-image" v-if="item.channel_image" />
|
<img :src="item.channel_image" class="channel-image" v-if="item.channel_image" />
|
||||||
<span class="channel-name" v-text="item.channel" />
|
<span class="channel-name" v-text="item.channel" />
|
||||||
</a>
|
</a>
|
||||||
|
@ -48,7 +54,17 @@ import Utils from "@/Utils";
|
||||||
export default {
|
export default {
|
||||||
components: {Dropdown, DropdownItem, MediaImage},
|
components: {Dropdown, DropdownItem, MediaImage},
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
emits: ['play', 'select', 'view', 'download'],
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'open-channel',
|
||||||
|
'play',
|
||||||
|
'remove-from-playlist',
|
||||||
|
'select',
|
||||||
|
'view',
|
||||||
|
],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
@ -64,6 +80,10 @@ export default {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
playlist: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -81,7 +101,8 @@ export default {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
max-height: 23.5em;
|
||||||
|
height: 100%;
|
||||||
cursor: initial !important;
|
cursor: initial !important;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
@ -107,7 +128,6 @@ export default {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="image-container"
|
<div class="image-container"
|
||||||
:class="{ 'with-image': !!item?.image }">
|
:class="{ 'with-image': !!item?.image }">
|
||||||
<div class="play-overlay" @click="$emit('play', item)" v-if="hasPlay">
|
<div class="play-overlay" @click="$emit(clickEvent, item)" v-if="hasPlay">
|
||||||
<i class="fas fa-play" />
|
<i :class="overlayIconClass" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="icon type-icon" v-if="typeIcons[item?.type]">
|
<span class="icon type-icon" v-if="typeIcons[item?.type]">
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
<img class="image" :src="item.image" :alt="item.title" v-if="item?.image" />
|
<img class="image" :src="item.image" :alt="item.title" v-if="item?.image" />
|
||||||
<div class="image" v-else>
|
<div class="image" v-else>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<i class="fas fa-play" />
|
<i :class="iconClass" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ import MediaUtils from "@/components/Media/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [Icons, MediaUtils],
|
mixins: [Icons, MediaUtils],
|
||||||
emits: ['play'],
|
emits: ['play', 'select'],
|
||||||
props: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
@ -58,6 +58,44 @@ export default {
|
||||||
typeIcons: Icons,
|
typeIcons: Icons,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
clickEvent() {
|
||||||
|
switch (this.item?.item_type) {
|
||||||
|
case 'channel':
|
||||||
|
case 'playlist':
|
||||||
|
case 'folder':
|
||||||
|
return 'select'
|
||||||
|
default:
|
||||||
|
return 'play'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
iconClass() {
|
||||||
|
switch (this.item?.item_type) {
|
||||||
|
case 'channel':
|
||||||
|
return 'fas fa-user'
|
||||||
|
case 'playlist':
|
||||||
|
return 'fas fa-list'
|
||||||
|
case 'folder':
|
||||||
|
return 'fas fa-folder'
|
||||||
|
default:
|
||||||
|
return 'fas fa-play'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
overlayIconClass() {
|
||||||
|
if (
|
||||||
|
this.item?.item_type === 'channel' ||
|
||||||
|
this.item?.item_type === 'playlist' ||
|
||||||
|
this.item?.item_type === 'folder'
|
||||||
|
) {
|
||||||
|
return 'fas fa-folder-open'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'fas fa-play'
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -5,14 +5,17 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<li v-for="(view, name) in displayedViews" :key="name" :title="view.displayName"
|
<li v-for="(view, name) in displayedViews" :key="name" :title="view.displayName"
|
||||||
:class="{selected: name === selectedView}" @click="$emit('input', name)">
|
:class="{selected: name === selectedView, ...customClasses[name]}" @click="input(name)">
|
||||||
<i :class="view.iconClass" />
|
<i :class="view.iconClass" />
|
||||||
</li>
|
</li>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Utils from '@/Utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [Utils],
|
||||||
emits: ['input', 'toggle'],
|
emits: ['input', 'toggle'],
|
||||||
props: {
|
props: {
|
||||||
selectedView: {
|
selectedView: {
|
||||||
|
@ -28,6 +31,10 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
downloadIconClass: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
|
||||||
views: {
|
views: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {
|
default: () => {
|
||||||
|
@ -42,6 +49,11 @@ export default {
|
||||||
displayName: 'Browser',
|
displayName: 'Browser',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
downloads: {
|
||||||
|
iconClass: 'fa fa-download',
|
||||||
|
displayName: 'Downloads',
|
||||||
|
},
|
||||||
|
|
||||||
torrents: {
|
torrents: {
|
||||||
iconClass: 'fa fa-magnet',
|
iconClass: 'fa fa-magnet',
|
||||||
displayName: 'Torrents',
|
displayName: 'Torrents',
|
||||||
|
@ -59,6 +71,22 @@ export default {
|
||||||
|
|
||||||
return views
|
return views
|
||||||
},
|
},
|
||||||
|
|
||||||
|
customClasses() {
|
||||||
|
return {
|
||||||
|
downloads: this.downloadIconClass.split(' ').reduce((acc, cls) => {
|
||||||
|
acc[cls] = true
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
input(view) {
|
||||||
|
this.$emit('input', view)
|
||||||
|
this.setUrlArgs({view: view})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -107,12 +135,8 @@ nav {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: .6em;
|
padding: .6em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
border-radius: 1.2em;
|
||||||
&.selected,
|
margin: 0 0.2em;
|
||||||
&:hover {
|
|
||||||
border-radius: 1.2em;
|
|
||||||
margin: 0 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $nav-entry-collapsed-hover-bg;
|
background: $nav-entry-collapsed-hover-bg;
|
||||||
|
@ -122,6 +146,9 @@ nav {
|
||||||
background: $nav-entry-collapsed-selected-bg;
|
background: $nav-entry-collapsed-selected-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
color: $ok-fg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,162 @@
|
||||||
|
<template>
|
||||||
|
<div class="playlist-adder-container">
|
||||||
|
<Loading v-if="loading" />
|
||||||
|
<TextPrompt ref="newPlaylistName" :visible="showNewPlaylist" @input="createPlaylist($event)">
|
||||||
|
Playlist name
|
||||||
|
</TextPrompt>
|
||||||
|
|
||||||
|
<div class="playlists">
|
||||||
|
<div class="playlist new-playlist">
|
||||||
|
<button @click="showNewPlaylist = true">
|
||||||
|
<i class="fa fa-plus" />
|
||||||
|
Create new playlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlist" v-for="playlist in playlists" :key="playlist.id">
|
||||||
|
<button @click="addToPlaylist(playlist.id)">
|
||||||
|
<i class="fa fa-list" />
|
||||||
|
{{ playlist.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Loading from "@/components/Loading";
|
||||||
|
import Utils from "@/Utils";
|
||||||
|
import TextPrompt from "@/components/elements/TextPrompt"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['done'],
|
||||||
|
mixins: [Utils],
|
||||||
|
components: {Loading, TextPrompt},
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
playlists: [],
|
||||||
|
showNewPlaylist: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async createPlaylist(name) {
|
||||||
|
name = name?.trim()
|
||||||
|
if (!name?.length)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playlist = await this.request('youtube.create_playlist', {
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.request('youtube.add_to_playlist', {
|
||||||
|
playlist_id: playlist.id,
|
||||||
|
video_id: this.item.id || this.item.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$emit('done')
|
||||||
|
this.notify({
|
||||||
|
text: 'Playlist created and video added',
|
||||||
|
image: {
|
||||||
|
icon: 'check',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
this.showNewPlaylist = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshPlaylists() {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.playlists = await this.request('youtube.get_playlists')
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addToPlaylist(playlistId) {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.request('youtube.add_to_playlist', {
|
||||||
|
playlist_id: playlistId,
|
||||||
|
video_id: this.item.id || this.item.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.notify({
|
||||||
|
text: 'Video added to playlist',
|
||||||
|
image: {
|
||||||
|
icon: 'check',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$emit('done')
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.refreshPlaylists()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.playlist-adder-container {
|
||||||
|
min-width: 300px;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.playlists {
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist {
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-playlist {
|
||||||
|
button {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: $default-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,13 +2,33 @@
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['back', 'path-change', 'play'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'back',
|
||||||
|
'create-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'path-change',
|
||||||
|
'play',
|
||||||
|
'remove-from-playlist',
|
||||||
|
'remove-playlist',
|
||||||
|
'rename-playlist',
|
||||||
|
],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
filter: {
|
filter: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
selectedPlaylist: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedChannel: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
|
|
@ -48,6 +48,9 @@ export default {
|
||||||
@import "../style.scss";
|
@import "../style.scss";
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
|
||||||
.path .token .icon {
|
.path .token .icon {
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,17 +8,36 @@
|
||||||
|
|
||||||
<div class="body" v-else>
|
<div class="body" v-else>
|
||||||
<Feed :filter="filter"
|
<Feed :filter="filter"
|
||||||
@play="$emit('play', $event)" v-if="selectedView === 'feed'" />
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@open-channel="selectChannelFromItem"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
v-if="selectedView === 'feed'"
|
||||||
|
/>
|
||||||
|
|
||||||
<Playlists :filter="filter"
|
<Playlists :filter="filter"
|
||||||
:selected-playlist="selectedPlaylist"
|
:selected-playlist="selectedPlaylist_"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@open-channel="selectChannelFromItem"
|
||||||
@play="$emit('play', $event)"
|
@play="$emit('play', $event)"
|
||||||
|
@remove-from-playlist="removeFromPlaylist"
|
||||||
@select="onPlaylistSelected"
|
@select="onPlaylistSelected"
|
||||||
v-else-if="selectedView === 'playlists'" />
|
v-else-if="selectedView === 'playlists'"
|
||||||
|
/>
|
||||||
|
|
||||||
<Subscriptions :filter="filter"
|
<Subscriptions :filter="filter"
|
||||||
:selected-channel="selectedChannel"
|
:selected-channel="selectedChannel_"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
@play="$emit('play', $event)"
|
@play="$emit('play', $event)"
|
||||||
@select="onChannelSelected"
|
@select="onChannelSelected"
|
||||||
v-else-if="selectedView === 'subscriptions'" />
|
v-else-if="selectedView === 'subscriptions'"
|
||||||
|
/>
|
||||||
|
|
||||||
<Index @select="selectView" v-else />
|
<Index @select="selectView" v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,8 +71,8 @@ export default {
|
||||||
return {
|
return {
|
||||||
youtubeConfig: null,
|
youtubeConfig: null,
|
||||||
selectedView: null,
|
selectedView: null,
|
||||||
selectedPlaylist: null,
|
selectedPlaylist_: null,
|
||||||
selectedChannel: null,
|
selectedChannel_: null,
|
||||||
path: [],
|
path: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -87,12 +106,36 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async removeFromPlaylist(event) {
|
||||||
|
const playlistId = event.playlist_id
|
||||||
|
const videoId = event.item.url
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.request('youtube.remove_from_playlist', {
|
||||||
|
playlist_id: playlistId,
|
||||||
|
video_id: videoId,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPlaylist(name) {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.request('youtube.create_playlist', {name: name})
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
if (view?.length) {
|
if (view?.length) {
|
||||||
this.path = [
|
this.path = [
|
||||||
|
@ -107,22 +150,86 @@ 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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
onChannelSelected(channel) {
|
onChannelSelected(channel) {
|
||||||
this.selectedChannel = channel.id
|
this.selectedChannel_ = channel
|
||||||
|
if (!channel)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.selectedView = 'subscriptions'
|
||||||
this.path.push({
|
this.path.push({
|
||||||
title: channel.name,
|
title: channel.name,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
initView() {
|
||||||
|
const args = this.getUrlArgs()
|
||||||
|
|
||||||
|
if (args.section)
|
||||||
|
this.selectedView = args.section
|
||||||
|
|
||||||
|
if (this.selectedView)
|
||||||
|
this.selectView(this.selectedView)
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectChannelFromItem(item) {
|
||||||
|
if (!item.channel_url)
|
||||||
|
return
|
||||||
|
|
||||||
|
const channel = await this.request(
|
||||||
|
'youtube.get_channel',
|
||||||
|
{id: item.channel_url.split('/').pop()}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!channel)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.onChannelSelected(channel)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
selectedPlaylist() {
|
||||||
|
this.onPlaylistSelected(this.selectedPlaylist)
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedPlaylist_(value) {
|
||||||
|
if (value == null)
|
||||||
|
this.setUrlArgs({playlist: null})
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedChannel() {
|
||||||
|
this.onChannelSelected(this.selectedChannel)
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedChannel_(value) {
|
||||||
|
if (value == null)
|
||||||
|
this.setUrlArgs({channel: null})
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedView() {
|
||||||
|
this.setUrlArgs({section: this.selectedView})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadYoutubeConfig()
|
this.loadYoutubeConfig()
|
||||||
|
this.initView()
|
||||||
|
this.onPlaylistSelected(this.selectedPlaylist)
|
||||||
|
this.onChannelSelected(this.selectedChannel)
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
this.setUrlArgs({section: null})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -140,6 +247,7 @@ export default {
|
||||||
.body {
|
.body {
|
||||||
height: calc(100% - $media-nav-height - 2px);
|
height: calc(100% - $media-nav-height - 2px);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,35 +1,59 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="media-youtube-channel" @scroll="onScroll">
|
<div class="media-youtube-channel">
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
|
|
||||||
<div class="channel" @scroll="onScroll" v-else-if="channel">
|
<div class="channel" v-else-if="channel">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
<img :src="channel.banner" v-if="channel?.banner?.length" />
|
<img :src="channel.banner" v-if="channel?.banner?.length" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row info-container">
|
||||||
<a :href="channel.url" target="_blank" rel="noopener noreferrer">
|
|
||||||
<div class="image">
|
|
||||||
<img :src="channel.image" v-if="channel?.image?.length" />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<a class="title" :href="channel.url" target="_blank" rel="noopener noreferrer">
|
<div class="row">
|
||||||
{{ channel?.name }}
|
<div class="title-container">
|
||||||
</a>
|
<a :href="channel.url" target="_blank" rel="noopener noreferrer" v-if="channel?.image?.length">
|
||||||
<div class="description">{{ channel?.description }}</div>
|
<div class="image">
|
||||||
|
<img :src="channel.image" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="title" :href="channel.url" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ channel?.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button :title="subscribed ? 'Unsubscribe' : 'Subscribe'" @click="toggleSubscription">
|
||||||
|
{{ subscribed ? 'Unsubscribe' : 'Subscribe' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="subscribers" v-if="channel.subscribers != null && (channel.subscribers || 0) >= 0">
|
||||||
|
{{ channel.subscribers }} subscribers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="description" v-if="channel?.description">
|
||||||
|
{{ channel.description }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Results :results="channel.items"
|
<Results :results="channel.items"
|
||||||
:filter="filter"
|
:filter="filter"
|
||||||
|
:result-index-step="null"
|
||||||
:selected-result="selectedResult"
|
:selected-result="selectedResult"
|
||||||
ref="results"
|
ref="results"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@open-channel="$emit('open-channel', $event)"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@scroll-end="loadNextPage"
|
||||||
@select="selectedResult = $event"
|
@select="selectedResult = $event"
|
||||||
@play="$emit('play', $event)" />
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -40,8 +64,15 @@ import Results from "@/components/panels/Media/Results";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['play'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'open-channel',
|
||||||
|
'play',
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Loading,
|
Loading,
|
||||||
Results,
|
Results,
|
||||||
|
@ -65,6 +96,7 @@ export default {
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingNextPage: false,
|
loadingNextPage: false,
|
||||||
selectedResult: null,
|
selectedResult: null,
|
||||||
|
subscribed: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -81,107 +113,105 @@ export default {
|
||||||
async loadChannel() {
|
async loadChannel() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
try {
|
try {
|
||||||
this.channel = await this.request('youtube.get_channel', {id: this.id})
|
await this.updateChannel(true)
|
||||||
|
this.subscribed = await this.request('youtube.is_subscribed', {channel_id: this.id})
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateChannel(init) {
|
||||||
|
const channel = await this.request(
|
||||||
|
'youtube.get_channel',
|
||||||
|
{id: this.id, next_page_token: this.channel?.next_page_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemsByUrl = this.itemsByUrl || {}
|
||||||
|
let items = channel.items
|
||||||
|
.filter(item => !itemsByUrl[item.url])
|
||||||
|
.map(item => {
|
||||||
|
return {
|
||||||
|
type: 'youtube',
|
||||||
|
...item,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
items = this.channel.items.concat(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.channel = channel
|
||||||
|
this.channel.items = items
|
||||||
|
},
|
||||||
|
|
||||||
async loadNextPage() {
|
async loadNextPage() {
|
||||||
if (!this.channel?.next_page_token || this.loadingNextPage)
|
if (!this.channel?.next_page_token || this.loadingNextPage) {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingNextPage = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextPage = await this.request(
|
await this.timeout(500)
|
||||||
'youtube.get_channel',
|
await this.updateChannel()
|
||||||
{id: this.id, next_page_token: this.channel.next_page_token}
|
|
||||||
)
|
|
||||||
|
|
||||||
this.channel.items.push(...nextPage.items.filter(item => !this.itemsByUrl[item.url]))
|
|
||||||
this.channel.next_page_token = nextPage.next_page_token
|
|
||||||
this.$refs.results.maxResultIndex += this.$refs.results.resultIndexStep
|
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingNextPage = false
|
this.loadingNextPage = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onScroll(e) {
|
async toggleSubscription() {
|
||||||
const el = e.target
|
const action = this.subscribed ? 'unsubscribe' : 'subscribe'
|
||||||
if (!el)
|
await this.request(`youtube.${action}`, {channel_id: this.id})
|
||||||
return
|
this.subscribed = !this.subscribed
|
||||||
|
|
||||||
const bottom = (el.scrollHeight - el.scrollTop) <= el.clientHeight + 150
|
|
||||||
if (!bottom)
|
|
||||||
return
|
|
||||||
|
|
||||||
this.loadNextPage()
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.loadChannel()
|
this.setUrlArgs({channel: this.id})
|
||||||
|
await this.loadChannel()
|
||||||
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
this.setUrlArgs({channel: null})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</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;
|
||||||
|
|
||||||
|
.channel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
border-bottom: $default-border-2;
|
.title-container {
|
||||||
padding-bottom: 0.5em;
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.banner {
|
.actions {
|
||||||
max-height: 200px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
img {
|
button {
|
||||||
max-width: 100%;
|
background: $default-bg-7;
|
||||||
max-height: 100%;
|
padding: 0.5em 1em;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover-bg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.subscribers {
|
||||||
height: 100px;
|
font-size: 0.8em;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,10 @@
|
||||||
:filter="filter"
|
:filter="filter"
|
||||||
:sources="{'youtube': true}"
|
:sources="{'youtube': true}"
|
||||||
:selected-result="selectedResult"
|
:selected-result="selectedResult"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@open-channel="$emit('open-channel', $event)"
|
||||||
@select="selectedResult = $event"
|
@select="selectedResult = $event"
|
||||||
@play="$emit('play', $event)"
|
@play="$emit('play', $event)"
|
||||||
v-else />
|
v-else />
|
||||||
|
@ -22,8 +26,15 @@ import Results from "@/components/panels/Media/Results";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['play'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'open-channel',
|
||||||
|
'play',
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Loading,
|
Loading,
|
||||||
NoItems,
|
NoItems,
|
||||||
|
|
|
@ -1,17 +1,63 @@
|
||||||
<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">
|
|
||||||
No videos found.
|
|
||||||
</NoItems>
|
|
||||||
|
|
||||||
<Results :results="items"
|
<div class="playlist-container" v-else>
|
||||||
:sources="{'youtube': true}"
|
<div class="header">
|
||||||
:filter="filter"
|
<div class="banner">
|
||||||
:selected-result="selectedResult"
|
<img :src="metadata?.image" v-if="metadata?.image?.length" />
|
||||||
@select="selectedResult = $event"
|
</div>
|
||||||
@play="$emit('play', $event)"
|
|
||||||
v-else />
|
<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.
|
||||||
|
</NoItems>
|
||||||
|
|
||||||
|
<Results :results="items"
|
||||||
|
:sources="{'youtube': true}"
|
||||||
|
:filter="filter"
|
||||||
|
:playlist="id"
|
||||||
|
:selected-result="selectedResult"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@open-channel="$emit('open-channel', $event)"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@remove-from-playlist="$emit('remove-from-playlist', $event)"
|
||||||
|
@select="selectedResult = $event"
|
||||||
|
v-else />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -22,8 +68,16 @@ import Results from "@/components/panels/Media/Results";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['play'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'open-channel',
|
||||||
|
'play',
|
||||||
|
'remove-from-playlist',
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Loading,
|
Loading,
|
||||||
NoItems,
|
NoItems,
|
||||||
|
@ -40,6 +94,11 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
metadata: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -50,6 +109,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
|
||||||
|
@ -67,13 +136,36 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.setUrlArgs({playlist: this.id})
|
||||||
this.loadItems()
|
this.loadItems()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
unmounted() {
|
||||||
|
this.setUrlArgs({playlist: null})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</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>
|
||||||
|
|
|
@ -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.
|
||||||
|
@ -13,36 +13,130 @@
|
||||||
@click="$emit('select', playlist)">
|
@click="$emit('select', playlist)">
|
||||||
<MediaImage :item="playlist" :has-play="false" />
|
<MediaImage :item="playlist" :has-play="false" />
|
||||||
<div class="title">{{ playlist.name }}</div>
|
<div class="title">{{ playlist.name }}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button title="Remove" @click.stop="deletedPlaylist = playlist.id">
|
||||||
|
<i class="fa fa-trash" />
|
||||||
|
</button>
|
||||||
|
<button title="Edit" @click.stop="editedPlaylist = playlist.id">
|
||||||
|
<i class="fa fa-pencil" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlist-body" v-else>
|
<div class="playlist-body" v-else>
|
||||||
<Playlist :id="selectedPlaylist" :filter="filter" @play="$emit('play', $event)" />
|
<Playlist
|
||||||
|
:id="selectedPlaylist.id"
|
||||||
|
:filter="filter"
|
||||||
|
:metadata="playlistsById[selectedPlaylist.id] || selectedPlaylist"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@open-channel="$emit('open-channel', $event)"
|
||||||
|
@remove-from-playlist="$emit('remove-from-playlist', {item: $event, playlist_id: selectedPlaylist.id})"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TextPrompt
|
||||||
|
:visible="showCreatePlaylist"
|
||||||
|
@input="createPlaylist($event)"
|
||||||
|
@close="showCreatePlaylist = false"
|
||||||
|
>
|
||||||
|
Playlist name
|
||||||
|
</TextPrompt>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
ref="removePlaylist"
|
||||||
|
title="Remove Playlist"
|
||||||
|
:visible="deletedPlaylist != null"
|
||||||
|
@close="deletedPlaylist = null"
|
||||||
|
@input="removePlaylist"
|
||||||
|
>
|
||||||
|
Are you sure you want to remove this playlist?
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
ref="editPlaylist"
|
||||||
|
title="Edit Playlist"
|
||||||
|
:visible="editedPlaylist != null"
|
||||||
|
@close="clearEditPlaylist"
|
||||||
|
@open="onEditPlaylistOpen"
|
||||||
|
>
|
||||||
|
<form class="edit-playlist-form" @submit.prevent="editPlaylist">
|
||||||
|
<div class="row">
|
||||||
|
<input ref="editPlaylistName" placeholder="Playlist name" v-model="editedPlaylistName" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<input placeholder="Playlist description" v-model="editedPlaylistDescription" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row buttons">
|
||||||
|
<div class="btn-container col-6">
|
||||||
|
<button type="submit">
|
||||||
|
<i class="fa fa-check" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-container col-6">
|
||||||
|
<button @click="clearEditPlaylist">
|
||||||
|
<i class="fa fa-times" /> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<FloatingButton
|
||||||
|
icon-class="fa fa-plus"
|
||||||
|
title="Create Playlist"
|
||||||
|
@click="showCreatePlaylist = true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import ConfirmDialog from "@/components/elements/ConfirmDialog";
|
||||||
|
import FloatingButton from "@/components/elements/FloatingButton";
|
||||||
import MediaImage from "@/components/panels/Media/MediaImage";
|
import MediaImage from "@/components/panels/Media/MediaImage";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
import NoItems from "@/components/elements/NoItems";
|
import NoItems from "@/components/elements/NoItems";
|
||||||
import Loading from "@/components/Loading";
|
import Loading from "@/components/Loading";
|
||||||
import Playlist from "./Playlist";
|
import Playlist from "./Playlist";
|
||||||
|
import TextPrompt from "@/components/elements/TextPrompt"
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['play', 'select'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'create-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'open-channel',
|
||||||
|
'play',
|
||||||
|
'remove-from-playlist',
|
||||||
|
'remove-playlist',
|
||||||
|
'rename-playlist',
|
||||||
|
'select',
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
ConfirmDialog,
|
||||||
|
FloatingButton,
|
||||||
Loading,
|
Loading,
|
||||||
MediaImage,
|
MediaImage,
|
||||||
|
Modal,
|
||||||
NoItems,
|
NoItems,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
TextPrompt,
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
selectedPlaylist: {
|
selectedPlaylist: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -54,8 +148,13 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
deletedPlaylist: null,
|
||||||
|
editedPlaylist: null,
|
||||||
|
editedPlaylistName: '',
|
||||||
|
editedPlaylistDescription: '',
|
||||||
playlists: [],
|
playlists: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
showCreatePlaylist: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -79,10 +178,82 @@ export default {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createPlaylist(name) {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.request('youtube.create_playlist', {name: name})
|
||||||
|
this.showCreatePlaylist = false
|
||||||
|
this.loadPlaylists()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removePlaylist() {
|
||||||
|
if (!this.deletedPlaylist)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.request('youtube.delete_playlist', {id: this.deletedPlaylist})
|
||||||
|
this.deletedPlaylist = null
|
||||||
|
this.loadPlaylists()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async editPlaylist() {
|
||||||
|
if (!this.editedPlaylist)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.request('youtube.rename_playlist', {
|
||||||
|
id: this.editedPlaylist,
|
||||||
|
name: this.editedPlaylistName,
|
||||||
|
description: this.editedPlaylistDescription,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.clearEditPlaylist()
|
||||||
|
this.loadPlaylists()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearEditPlaylist() {
|
||||||
|
this.editedPlaylist = null
|
||||||
|
this.editedPlaylistName = ''
|
||||||
|
this.editedPlaylistDescription = ''
|
||||||
|
this.$refs.editPlaylist.hide()
|
||||||
|
},
|
||||||
|
|
||||||
|
onEditPlaylistOpen() {
|
||||||
|
const playlist = this.playlistsById[this.editedPlaylist]
|
||||||
|
this.editedPlaylistName = playlist.name
|
||||||
|
this.editedPlaylistDescription = playlist.description
|
||||||
|
this.$nextTick(() => this.$refs.editPlaylistName.focus())
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.loadPlaylists()
|
await this.loadPlaylists()
|
||||||
|
|
||||||
|
const args = this.getUrlArgs()
|
||||||
|
if (args.playlist) {
|
||||||
|
const playlist = this.playlistsById[args.playlist]
|
||||||
|
if (playlist) {
|
||||||
|
this.$emit('select', playlist)
|
||||||
|
} else {
|
||||||
|
this.$emit('select', {id: args.playlist})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unmouted() {
|
||||||
|
this.setUrlArgs({section: null})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -90,12 +261,14 @@ export default {
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.media-youtube-playlists {
|
.media-youtube-playlists {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.playlist-body {
|
.playlist-body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.playlist.item) {
|
:deep(.playlist.item) {
|
||||||
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
@ -103,6 +276,33 @@ export default {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 0.5em;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0.9;
|
||||||
|
border-radius: 0 0 0.5em 0.5em;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $default-hover-fg;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
||||||
|
@ -111,5 +311,51 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.modal) {
|
||||||
|
.edit-playlist-form {
|
||||||
|
min-width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.row {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
border: $default-border;
|
||||||
|
border-radius: 1em;
|
||||||
|
padding: 0.5em;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid $selected-fg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0 0.5em;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-radius: 1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="media-youtube-subscriptions">
|
<div class="media-youtube-subscriptions">
|
||||||
<div class="subscriptions-index" v-if="!selectedChannel">
|
<div class="subscriptions-index" v-if="!selectedChannel?.id">
|
||||||
<Loading v-if="loading" />
|
<Loading v-if="loading" />
|
||||||
<NoItems :with-shadow="false" v-else-if="!channels?.length">
|
<NoItems :with-shadow="false" v-else-if="!channels?.length">
|
||||||
No channels found.
|
No channels found.
|
||||||
|
@ -20,7 +20,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="subscription-body" v-else>
|
<div class="subscription-body" v-else>
|
||||||
<Channel :id="selectedChannel" :filter="filter" @play="$emit('play', $event)" />
|
<Channel
|
||||||
|
:id="selectedChannel.id"
|
||||||
|
:filter="filter"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
|
@download-audio="$emit('download-audio', $event)"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -32,8 +39,15 @@ import Loading from "@/components/Loading";
|
||||||
import Utils from "@/Utils";
|
import Utils from "@/Utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
emits: ['play', 'select'],
|
|
||||||
mixins: [Utils],
|
mixins: [Utils],
|
||||||
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'play',
|
||||||
|
'select',
|
||||||
|
],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
Channel,
|
Channel,
|
||||||
Loading,
|
Loading,
|
||||||
|
@ -42,7 +56,7 @@ export default {
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
selectedChannel: {
|
selectedChannel: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -79,10 +93,18 @@ export default {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
initView() {
|
||||||
|
const args = this.getUrlArgs()
|
||||||
|
if (args.channel) {
|
||||||
|
this.$emit('select', {id: args.channel})
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.loadSubscriptions()
|
await this.loadSubscriptions()
|
||||||
|
this.initView()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
$banner-height: var(--banner-height);
|
||||||
|
$info-bg: rgba(0, 0, 0, 0.5);
|
||||||
|
$info-fg: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
@include until($tablet) {
|
||||||
|
.playlist-container, .channel {
|
||||||
|
--banner-height: 5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include from($tablet) {
|
||||||
|
.playlist-container, .channel {
|
||||||
|
--banner-height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-container {
|
||||||
|
max-height: var(--banner-height);
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
height: 100%;
|
||||||
|
align-content: center;
|
||||||
|
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);
|
||||||
|
}
|
|
@ -4,17 +4,24 @@
|
||||||
<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"
|
||||||
:item="item"
|
|
||||||
:selected="selectedResult === i"
|
|
||||||
:hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
|
:hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
|
||||||
|
:item="item"
|
||||||
|
:playlist="playlist"
|
||||||
|
:selected="selectedResult === i"
|
||||||
|
@add-to-playlist="$emit('add-to-playlist', item)"
|
||||||
|
@open-channel="$emit('open-channel', item)"
|
||||||
|
@remove-from-playlist="$emit('remove-from-playlist', item)"
|
||||||
@select="$emit('select', i)"
|
@select="$emit('select', i)"
|
||||||
@play="$emit('play', item)"
|
@play="$emit('play', item)"
|
||||||
@view="$emit('view', item)"
|
@view="$emit('view', item)"
|
||||||
@download="$emit('download', item)" />
|
@download="$emit('download', item)"
|
||||||
|
@download-audio="$emit('download-audio', item)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal ref="infoModal" title="Media info" @close="$emit('select', null)">
|
<Modal ref="infoModal" title="Media info" @close="$emit('select', null)">
|
||||||
<Info :item="results[selectedResult]"
|
<Info :item="results[selectedResult]"
|
||||||
|
:pluginName="pluginName"
|
||||||
@play="$emit('play', results[selectedResult])"
|
@play="$emit('play', results[selectedResult])"
|
||||||
v-if="selectedResult != null" />
|
v-if="selectedResult != null" />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -29,13 +36,28 @@ import Modal from "@/components/Modal";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {Info, Item, Loading, Modal},
|
components: {Info, Item, Loading, Modal},
|
||||||
emits: ['select', 'play', 'view', 'download', 'scroll-end'],
|
emits: [
|
||||||
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
|
'download-audio',
|
||||||
|
'open-channel',
|
||||||
|
'play',
|
||||||
|
'remove-from-playlist',
|
||||||
|
'scroll-end',
|
||||||
|
'select',
|
||||||
|
'view',
|
||||||
|
],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pluginName: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
|
||||||
results: {
|
results: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
@ -59,6 +81,10 @@ export default {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 25,
|
default: 25,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
playlist: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -69,14 +95,18 @@ export default {
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
visibleResults() {
|
visibleResults() {
|
||||||
return this.results
|
let results = this.results
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (!this.filter)
|
if (!this.filter?.length)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
return item.title.toLowerCase().includes(this.filter.toLowerCase())
|
return item.title.toLowerCase().includes(this.filter.toLowerCase())
|
||||||
})
|
})
|
||||||
.slice(0, this.maxResultIndex)
|
|
||||||
|
if (this.maxResultIndex != null)
|
||||||
|
results = results.slice(0, this.maxResultIndex)
|
||||||
|
|
||||||
|
return results
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -91,12 +121,19 @@ export default {
|
||||||
return
|
return
|
||||||
|
|
||||||
this.$emit('scroll-end')
|
this.$emit('scroll-end')
|
||||||
this.maxResultIndex += this.resultIndexStep
|
|
||||||
|
if (this.resultIndexStep != null)
|
||||||
|
this.maxResultIndex += this.resultIndexStep
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$watch('selectedResult', (value) => {
|
this.$watch('selectedResult', (value) => {
|
||||||
|
if (value?.item_type === 'playlist' || value?.item_type === 'channel') {
|
||||||
|
this.$emit('select', null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (value == null)
|
if (value == null)
|
||||||
this.$refs.infoModal?.close()
|
this.$refs.infoModal?.close()
|
||||||
else
|
else
|
||||||
|
@ -114,6 +151,7 @@ export default {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
@add-to-queue="$emit('load-tracks', {tracks: $event, play: false})"
|
@add-to-queue="$emit('load-tracks', {tracks: $event, play: false})"
|
||||||
@add-to-queue-and-play="$emit('load-tracks', {tracks: $event, play: true})"
|
@add-to-queue-and-play="$emit('load-tracks', {tracks: $event, play: true})"
|
||||||
@back="$emit('playlist-edit', null)"
|
@back="$emit('playlist-edit', null)"
|
||||||
|
@download="$emit('download', $event)"
|
||||||
@info="$emit('info', $event)"
|
@info="$emit('info', $event)"
|
||||||
@move="$emit('track-move', {...$event, playlist: editedPlaylist})"
|
@move="$emit('track-move', {...$event, playlist: editedPlaylist})"
|
||||||
@play="$emit('load-tracks', {tracks: [$event], play: true})"
|
@play="$emit('load-tracks', {tracks: [$event], play: true})"
|
||||||
|
@ -104,6 +105,7 @@ export default {
|
||||||
|
|
||||||
emits: [
|
emits: [
|
||||||
'add-to-playlist',
|
'add-to-playlist',
|
||||||
|
'download',
|
||||||
'info',
|
'info',
|
||||||
'load',
|
'load',
|
||||||
'load-tracks',
|
'load-tracks',
|
||||||
|
|
|
@ -1,45 +1,39 @@
|
||||||
import { createWebHistory, createRouter } from "vue-router";
|
import { createWebHistory, createRouter } from "vue-router";
|
||||||
import Dashboard from "@/views/Dashboard.vue";
|
|
||||||
import NotFound from "@/views/NotFound";
|
|
||||||
import Login from "@/views/Login";
|
|
||||||
import Register from "@/views/Register";
|
|
||||||
import Panel from "@/views/Panel";
|
|
||||||
import Plugin from "@/views/Plugin";
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "Panel",
|
name: "Panel",
|
||||||
component: Panel,
|
component: () => import(/* webpackChunkName: "panel" */ "@/views/Panel"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/dashboard/:name",
|
path: "/dashboard/:name",
|
||||||
name: "Dashboard",
|
name: "Dashboard",
|
||||||
component: Dashboard,
|
component: () => import(/* webpackChunkName: "dashboard" */ "@/views/Dashboard"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/plugin/:plugin",
|
path: "/plugin/:plugin",
|
||||||
name: "Plugin",
|
name: "Plugin",
|
||||||
component: Plugin,
|
component: () => import(/* webpackChunkName: "plugin" */ "@/views/Plugin"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
name: "Login",
|
name: "Login",
|
||||||
component: Login,
|
component: () => import(/* webpackChunkName: "login" */ "@/views/Login"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/register",
|
path: "/register",
|
||||||
name: "Register",
|
name: "Register",
|
||||||
component: Register,
|
component: () => import(/* webpackChunkName: "register" */ "@/views/Register"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/:catchAll(.*)",
|
path: "/:catchAll(.*)",
|
||||||
component: NotFound,
|
component: () => import(/* webpackChunkName: "notfound" */ "@/views/NotFound"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -26,3 +26,30 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
animation-duration: 2s;
|
||||||
|
-webkit-animation-duration: 2s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-name: glow;
|
||||||
|
-webkit-animation-name: glow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loop {
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
-webkit-animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
0% {opacity: 1; box-shadow: 0 0 5px #fff;}
|
||||||
|
10% {opacity: 0.9; box-shadow: 0 0 10px $active-glow-fg-1;}
|
||||||
|
20% {opacity: 0.8; box-shadow: 0 0 20px $active-glow-fg-1;}
|
||||||
|
30% {opacity: 0.7; box-shadow: 0 0 30px $active-glow-fg-1;}
|
||||||
|
40% {opacity: 0.6; box-shadow: 0 0 40px $active-glow-fg-1;}
|
||||||
|
50% {opacity: 0.5; box-shadow: 0 0 50px $active-glow-fg-1;}
|
||||||
|
60% {opacity: 0.6; box-shadow: 0 0 40px $active-glow-fg-1;}
|
||||||
|
70% {opacity: 0.7; box-shadow: 0 0 30px $active-glow-fg-1;}
|
||||||
|
80% {opacity: 0.8; box-shadow: 0 0 20px $active-glow-fg-1;}
|
||||||
|
90% {opacity: 0.9; box-shadow: 0 0 10px $active-glow-fg-1;}
|
||||||
|
100% {opacity: 1; box-shadow: 0 0 5px #fff;}
|
||||||
|
}
|
||||||
|
|
|
@ -87,6 +87,8 @@ $default-link-fg: #5f7869 !default;
|
||||||
/// Active
|
/// Active
|
||||||
$active-glow-bg-1: #d4ffe3 !default;
|
$active-glow-bg-1: #d4ffe3 !default;
|
||||||
$active-glow-bg-2: #9cdfb0 !default;
|
$active-glow-bg-2: #9cdfb0 !default;
|
||||||
|
$active-glow-fg-1: #32b646 !default;
|
||||||
|
$active-glow-fg-2: #5f7869 !default;
|
||||||
|
|
||||||
/// Hover
|
/// Hover
|
||||||
$default-hover-fg: #35b870 !default;
|
$default-hover-fg: #35b870 !default;
|
||||||
|
|
|
@ -72,7 +72,11 @@ export default {
|
||||||
action: action,
|
action: action,
|
||||||
args: args,
|
args: args,
|
||||||
}, timeout, showError);
|
}, timeout, showError);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
timeout(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -3,13 +3,17 @@ export default {
|
||||||
name: "DateTime",
|
name: "DateTime",
|
||||||
methods: {
|
methods: {
|
||||||
formatDate(date, year=false) {
|
formatDate(date, year=false) {
|
||||||
if (typeof date === 'string')
|
if (typeof date === 'number')
|
||||||
|
date = new Date(date * 1000)
|
||||||
|
else if (typeof date === 'string')
|
||||||
date = new Date(Date.parse(date))
|
date = new Date(Date.parse(date))
|
||||||
|
|
||||||
return date.toDateString().substring(0, year ? 15 : 10)
|
return date.toDateString().substring(0, year ? 15 : 10)
|
||||||
},
|
},
|
||||||
|
|
||||||
formatTime(date, seconds=true) {
|
formatTime(date, seconds=true) {
|
||||||
|
if (typeof date === 'number')
|
||||||
|
date = new Date(date * 1000)
|
||||||
if (typeof date === 'string')
|
if (typeof date === 'string')
|
||||||
date = new Date(Date.parse(date))
|
date = new Date(Date.parse(date))
|
||||||
|
|
||||||
|
@ -17,6 +21,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
formatDateTime(date, year=false, seconds=true, skipTimeIfMidnight=false) {
|
formatDateTime(date, year=false, seconds=true, skipTimeIfMidnight=false) {
|
||||||
|
if (typeof date === 'number')
|
||||||
|
date = new Date(date * 1000)
|
||||||
if (typeof date === 'string')
|
if (typeof date === 'string')
|
||||||
date = new Date(Date.parse(date))
|
date = new Date(Date.parse(date))
|
||||||
|
|
||||||
|
|
|
@ -114,6 +114,10 @@ export default {
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
round(value, decimals) {
|
||||||
|
return Number(Math.round(value+'e'+decimals)+'e-'+decimals);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -16,7 +16,7 @@ export default {
|
||||||
.reduce((acc, obj) => {
|
.reduce((acc, obj) => {
|
||||||
const tokens = obj.split('=')
|
const tokens = obj.split('=')
|
||||||
if (tokens[0]?.length)
|
if (tokens[0]?.length)
|
||||||
acc[tokens[0]] = tokens[1]
|
acc[tokens[0]] = decodeURIComponent(tokens[1])
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
},
|
},
|
||||||
|
@ -40,10 +40,24 @@ export default {
|
||||||
window.location.href = location
|
window.location.href = location
|
||||||
},
|
},
|
||||||
|
|
||||||
|
encodeValue(value) {
|
||||||
|
if (!value?.length || value === 'null' || value === 'undefined')
|
||||||
|
return ''
|
||||||
|
|
||||||
|
// Don't re-encode the value if it's already encoded
|
||||||
|
if (value.match(/%[0-9A-F]{2}/i))
|
||||||
|
return value
|
||||||
|
|
||||||
|
return encodeURIComponent(value)
|
||||||
|
},
|
||||||
|
|
||||||
fragmentFromArgs(args) {
|
fragmentFromArgs(args) {
|
||||||
return Object.entries(args)
|
return Object.entries(args)
|
||||||
|
.filter(
|
||||||
|
([key, value]) => this.encodeValue(key)?.length && this.encodeValue(value)?.length
|
||||||
|
)
|
||||||
.map(
|
.map(
|
||||||
([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
|
([key, value]) => `${this.encodeValue(key)}=${this.encodeValue(value)}`
|
||||||
)
|
)
|
||||||
.join('&')
|
.join('&')
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue