move
This commit is contained in:
113
src/components/dialogs/AlbumMerge.vue
Normal file
113
src/components/dialogs/AlbumMerge.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
title="Move all title..."
|
||||
buttonText="Move"
|
||||
buttonClass="accept"
|
||||
@accept="merge"
|
||||
@closed="closed"
|
||||
:closeOnButtonClick="false"
|
||||
:enableFooterButtons="acceptable"
|
||||
>
|
||||
<div class="flex-row" id="merge-content">
|
||||
<div class="flex-column">
|
||||
<h4 class="ma-left ma-top">From</h4>
|
||||
<AlbumItem class="ma" :item="source" />
|
||||
</div>
|
||||
<awesome-icon id="arrow-icon" icon="arrow-right" />
|
||||
<div class="flex-column">
|
||||
<DropDown class="ma-left ma-top">
|
||||
<input
|
||||
type="search"
|
||||
v-model="search"
|
||||
@search="searchTermChanged"
|
||||
placeholder="Into"
|
||||
/>
|
||||
<template v-slot:dropdown-content>
|
||||
<div class="flex-column">
|
||||
<h3 class="ma" v-if="albums.length == 0">Enter an album title</h3>
|
||||
<AlbumItem
|
||||
class="ma8"
|
||||
v-for="album in albums"
|
||||
:key="album._id"
|
||||
:item="album"
|
||||
@click="select(album)"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
<AlbumItem class="ma" :item="target" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
albums: [],
|
||||
search: "",
|
||||
searchTimer: 0,
|
||||
source: {},
|
||||
target: { covers: {} },
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
closed() {
|
||||
this.albums = [];
|
||||
this.source = {};
|
||||
this.target = { covers: {} };
|
||||
this.search = "";
|
||||
clearTimeout(this.searchTimer);
|
||||
},
|
||||
open(source) {
|
||||
this.source = source;
|
||||
this.$refs.dialogWindow.open();
|
||||
},
|
||||
merge() {
|
||||
this.$store
|
||||
.dispatch("albums/move", {
|
||||
source: this.source._id,
|
||||
target: this.target._id,
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch("albums/loadAlbum", this.target._id);
|
||||
this.$store.dispatch("albums/selectAlbumById", this.target._id);
|
||||
this.$store.dispatch("albums/remove", this.source._id);
|
||||
this.$refs.dialogWindow.close();
|
||||
});
|
||||
},
|
||||
searchTermChanged() {
|
||||
clearTimeout(this.searchTimer);
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.$store.dispatch("albums/filter", this.search).then((result) => {
|
||||
this.albums = result;
|
||||
});
|
||||
}, 250);
|
||||
},
|
||||
select(album) {
|
||||
this.target = album;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
acceptable() {
|
||||
return this.source._id != undefined && this.target._id != undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
width: 128px;
|
||||
}
|
||||
#arrow-icon {
|
||||
align-self: center;
|
||||
}
|
||||
#merge-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
</style>
|
||||
494
src/components/dialogs/AlbumViewer.vue
Normal file
494
src/components/dialogs/AlbumViewer.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
:title="album_title"
|
||||
@canceled="closed"
|
||||
@opened="opened"
|
||||
:showFooter="false"
|
||||
:disableXscroll="true"
|
||||
:disableYscroll="true"
|
||||
>
|
||||
<div id="albumViewer">
|
||||
<div id="header" class="flex-column">
|
||||
<div id="background" :style="coverBackground" />
|
||||
|
||||
<div id="albumList" class="flex-row z1" @scroll="loadingAlbums()">
|
||||
<div class="dummyAlbum" />
|
||||
<div id="loadPrevAlbums" />
|
||||
|
||||
<AlbumItem
|
||||
v-for="album in albums"
|
||||
:key="album._id"
|
||||
:item="album"
|
||||
class="ma"
|
||||
:class="{ focus: album._id == selectedAlbum._id }"
|
||||
:id="album._id"
|
||||
@touchend="albumAutoSelect"
|
||||
@click="selectAlbum(album)"
|
||||
@dblclick="dblclick"
|
||||
/>
|
||||
|
||||
<div id="loadNextAlbums" />
|
||||
<div class="dummyAlbum" />
|
||||
</div>
|
||||
<awesome-icon
|
||||
icon="star"
|
||||
size="2x"
|
||||
class="favourite ma4"
|
||||
:class="{ active: isFavourite }"
|
||||
@click="toggleFavourite"
|
||||
/>
|
||||
<div id="stats" class="flex-row grow z1 pa4-bottom">
|
||||
<DropDown v-if="$store.getters['user/isAdministrator']">
|
||||
<button class="flat pa8-left pa8-right" :title="visibility_text">
|
||||
<awesome-icon :icon="visibility_icon" />
|
||||
</button>
|
||||
<template v-slot:dropdown-content>
|
||||
<div>
|
||||
<button
|
||||
v-for="(item, i) in $store.state.system.lists.visibility"
|
||||
:key="i"
|
||||
@click="setVisibility(item)"
|
||||
>
|
||||
<awesome-icon :icon="getVisibilityIcon(item)" />{{
|
||||
getVisibilityText(item)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
|
||||
<span class="grow center" @click="scrollIntoCenter(selectedAlbum)">
|
||||
<b>{{ album_title }}</b> by
|
||||
<b @click="gotoArtist" class="pointer">{{
|
||||
selectedAlbum.artist_name
|
||||
}}</b>
|
||||
<br />
|
||||
<span v-if="album_year">
|
||||
from year <b>{{ album_year }}</b> </span
|
||||
><br />
|
||||
<b>{{ album_tracks.length }}</b> Tracks with a duration of
|
||||
<b>{{ album_duration }}</b>
|
||||
</span>
|
||||
|
||||
<DropDown v-if="$store.getters['user/isAdministrator']">
|
||||
<button class="flat pa8-left pa8-right">
|
||||
<awesome-icon icon="ellipsis-v" />
|
||||
</button>
|
||||
<template v-slot:dropdown-content>
|
||||
<div>
|
||||
<button @click="uploadNewCover">
|
||||
<awesome-icon icon="image" />Set new Cover...
|
||||
</button>
|
||||
<button @click="resetCover">
|
||||
<awesome-icon icon="eraser" />Reset Cover
|
||||
</button>
|
||||
<hr />
|
||||
<button @click="mergeAlbum">
|
||||
<awesome-icon icon="compress-alt" />Merge Albums...
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="trackList" class="tracks">
|
||||
<li v-for="track in selectedAlbum.tracks" :key="track._id">
|
||||
<TrackItem :track="track" :showCover="false" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<AlbumMerge ref="mergeDialog" />
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AlbumMerge from "./AlbumMerge.vue";
|
||||
import TrackItem from "../Track";
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
move: 152,
|
||||
albums: [],
|
||||
scrollTimer: 0,
|
||||
loadingPrev: false,
|
||||
loadingNext: false,
|
||||
elements: {},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (window.innerWidth <= 480 || window.innerHeight <= 480) {
|
||||
this.move = 120;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
albumAutoSelect() {
|
||||
this.albums.forEach((album) => {
|
||||
let center_client = document.documentElement.clientWidth / 2;
|
||||
let e = document.getElementById(album._id);
|
||||
let r = e.getBoundingClientRect();
|
||||
let center_element = center_client - r.left - r.width / 2;
|
||||
if (center_element < this.move / 2 && center_element > -this.move / 2) {
|
||||
if (album._id == this.selectedAlbum._id) {
|
||||
this.scrollIntoCenter(album, "smooth");
|
||||
} else {
|
||||
this.selectAlbum(album);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
readElements() {
|
||||
if (document.getElementById("header") == undefined) {
|
||||
return false;
|
||||
}
|
||||
this.elements.prev = document
|
||||
.getElementById("loadPrevAlbums")
|
||||
.getBoundingClientRect();
|
||||
|
||||
this.elements.next = document
|
||||
.getElementById("loadNextAlbums")
|
||||
.getBoundingClientRect();
|
||||
|
||||
this.elements.header = document
|
||||
.getElementById("header")
|
||||
.getBoundingClientRect();
|
||||
|
||||
this.elements.albums = document.getElementById("albumList");
|
||||
|
||||
return true;
|
||||
},
|
||||
dblclick() {
|
||||
this.$store.commit("tracks/resetSelectedTrack");
|
||||
this.$store.commit("radios/resetSelectedRadio");
|
||||
this.$store.dispatch("tracks/playContainer", this.selectedAlbum);
|
||||
},
|
||||
gotoArtist() {
|
||||
let artist = this.$store.getters["artists/collection"].find(
|
||||
(f) => f._id == this.selectedAlbum.artist_id
|
||||
);
|
||||
if (artist) {
|
||||
this.$store.dispatch("artists/selectArtist", artist);
|
||||
} else {
|
||||
this.$store
|
||||
.dispatch("artists/loadArtist", this.selectedAlbum.artist_id)
|
||||
.then((artist) => {
|
||||
this.$store.dispatch("artists/selectArtist", artist);
|
||||
});
|
||||
}
|
||||
},
|
||||
gotoNextAlbum() {
|
||||
let i = this.albums.indexOf(this.selectedAlbum);
|
||||
if (i < this.albums.length - 1) {
|
||||
this.selectAlbum(this.albums[++i]);
|
||||
}
|
||||
},
|
||||
gotoPrevAlbum() {
|
||||
let i = this.albums.indexOf(this.selectedAlbum);
|
||||
if (i > 0) {
|
||||
this.selectAlbum(this.albums[--i]);
|
||||
}
|
||||
},
|
||||
gotoTrack() {
|
||||
if (this.$route.query.play) {
|
||||
let track = this.selectedAlbum.tracks.find(
|
||||
(f) => f._id == this.$route.query.play
|
||||
);
|
||||
if (track) {
|
||||
this.$store.dispatch("tracks/play", track);
|
||||
}
|
||||
}
|
||||
},
|
||||
closed() {
|
||||
if (
|
||||
(window.history.state.back &&
|
||||
window.history.state.back.indexOf("?") == -1) ||
|
||||
window.history.state.back.startsWith("/search")
|
||||
) {
|
||||
this.$router.back();
|
||||
} else {
|
||||
this.$store.dispatch("albums/resetSelectedAlbum");
|
||||
}
|
||||
this.albums = [];
|
||||
},
|
||||
keydownListener(e) {
|
||||
if (e.key == "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
this.elements.albums.scrollLeft -= 0;
|
||||
this.gotoPrevAlbum();
|
||||
}
|
||||
if (e.key == "ArrowRight") {
|
||||
e.preventDefault();
|
||||
this.elements.albums.scrollLeft += 0;
|
||||
this.gotoNextAlbum();
|
||||
}
|
||||
},
|
||||
loadingAlbums() {
|
||||
clearTimeout(this.scrollTimer);
|
||||
if (!this.readElements()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let posPrev = this.elements.prev.left - this.elements.header.left;
|
||||
let posNext = this.elements.header.right - this.elements.next.right;
|
||||
|
||||
if (posPrev >= -this.move && !this.loadingPrev) {
|
||||
this.loadingPrev = true;
|
||||
this.$store
|
||||
.dispatch("albums/getPrevTo", this.albums[0])
|
||||
.then((album) => {
|
||||
if (album) {
|
||||
this.albums.unshift(album);
|
||||
this.elements.albums.scrollLeft += this.move;
|
||||
}
|
||||
this.loadingPrev = false;
|
||||
});
|
||||
} else if (posPrev < -this.move * 3 && !this.loadingPrev) {
|
||||
this.loadingPrev = true;
|
||||
this.elements.albums.scrollLeft -= this.move;
|
||||
this.albums.shift();
|
||||
this.loadingPrev = false;
|
||||
}
|
||||
|
||||
if (posNext >= -this.move && !this.loadingNext) {
|
||||
this.loadingNext = true;
|
||||
this.$store
|
||||
.dispatch("albums/getNextTo", this.albums[this.albums.length - 1])
|
||||
.then((album) => {
|
||||
if (album) {
|
||||
this.albums.push(album);
|
||||
}
|
||||
this.loadingNext = false;
|
||||
});
|
||||
} else if (posNext < -this.move * 3 && !this.loadingNext) {
|
||||
this.loadingNext = true;
|
||||
this.albums.pop();
|
||||
this.loadingNext = false;
|
||||
}
|
||||
},
|
||||
mergeAlbum() {
|
||||
this.$refs.mergeDialog.open(this.selectedAlbum);
|
||||
},
|
||||
opened() {
|
||||
this.$nextTick(() => {
|
||||
this.scrollIntoCenter(this.selectedAlbum);
|
||||
});
|
||||
},
|
||||
setVisibility(visibility) {
|
||||
this.selectedAlbum.visibility = visibility;
|
||||
this.$store.dispatch("albums/updateAlbum", this.selectedAlbum);
|
||||
},
|
||||
toggleFavourite() {
|
||||
this.$store.dispatch("user/toggleFavourite", {
|
||||
itemId: this.selectedAlbum._id,
|
||||
type: "album",
|
||||
});
|
||||
},
|
||||
uploadNewCover() {
|
||||
this.$store.dispatch("albums/uploadNewCover", this.selectedAlbum);
|
||||
},
|
||||
resetCover() {
|
||||
this.$store.dispatch("albums/resetCover", this.selectedAlbum);
|
||||
},
|
||||
getVisibilityIcon(visibility) {
|
||||
return visibility == "global"
|
||||
? "globe"
|
||||
: visibility == "instance"
|
||||
? "server"
|
||||
: visibility == "hidden"
|
||||
? "eye-slash"
|
||||
: "user";
|
||||
},
|
||||
getVisibilityText(visibility) {
|
||||
return visibility == "global"
|
||||
? "Global"
|
||||
: visibility == "instance"
|
||||
? "On this server"
|
||||
: visibility == "hidden"
|
||||
? "Hide this Album"
|
||||
: "Only for me";
|
||||
},
|
||||
scrollIntoCenter(album, behavior = "auto") {
|
||||
let e = document.getElementById(album._id);
|
||||
if (e) {
|
||||
e.scrollIntoView({ behavior: behavior, inline: "center" });
|
||||
}
|
||||
},
|
||||
selectAlbum(album) {
|
||||
this.$store.dispatch("albums/selectAlbum", album);
|
||||
this.scrollIntoCenter(album);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
prevAlbum: ["albums/prevAlbum"],
|
||||
nextAlbum: ["albums/nextAlbum"],
|
||||
selectedAlbum: ["albums/selectedAlbum"],
|
||||
selectedTrack: ["tracks/selectedTrack"],
|
||||
favourites: ["user/favourites"],
|
||||
}),
|
||||
album_title() {
|
||||
return this.selectedAlbum.title;
|
||||
},
|
||||
album_year() {
|
||||
return this.selectedAlbum.year;
|
||||
},
|
||||
album_tracks() {
|
||||
return this.selectedAlbum.tracks || [];
|
||||
},
|
||||
album_duration() {
|
||||
if (!this.selectedAlbum.tracks) {
|
||||
return 0;
|
||||
}
|
||||
let duration = 0;
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
|
||||
this.selectedAlbum.tracks.forEach((track) => {
|
||||
duration += track.duration;
|
||||
});
|
||||
|
||||
if (duration >= 3600) {
|
||||
hours = parseInt(duration / 3600);
|
||||
duration -= hours * 3600;
|
||||
}
|
||||
|
||||
minutes = parseInt(duration / 60);
|
||||
seconds = parseInt(duration - minutes * 60);
|
||||
return (
|
||||
(hours > 0 ? hours + ":" : "") +
|
||||
(minutes < 10 ? "0" : "") +
|
||||
minutes +
|
||||
":" +
|
||||
(seconds < 10 ? "0" : "") +
|
||||
seconds
|
||||
);
|
||||
},
|
||||
|
||||
coverBackground() {
|
||||
return "background-image: url('" + this.cover + "')";
|
||||
},
|
||||
cover() {
|
||||
return (
|
||||
this.selectedAlbum.covers.cover256 || "/static/icons/dummy/album.svg"
|
||||
);
|
||||
},
|
||||
|
||||
visibility_icon() {
|
||||
return this.selectedAlbum.visibility == "global"
|
||||
? "globe"
|
||||
: this.selectedAlbum.visibility == "instance"
|
||||
? "server"
|
||||
: this.selectedAlbum.visibility == "hidden"
|
||||
? "eye-slash"
|
||||
: "user";
|
||||
},
|
||||
visibility_text() {
|
||||
return this.selectedAlbum.visibility == "global"
|
||||
? "Visible for the whole world"
|
||||
: this.selectedAlbum.visibility == "instance"
|
||||
? "Visible on this instance"
|
||||
: this.selectedAlbum.visibility == "hidden"
|
||||
? "Hidden for all users"
|
||||
: "Visible only for me";
|
||||
},
|
||||
isFavourite() {
|
||||
return (
|
||||
this.favourites.find((f) => f.itemId == this.selectedAlbum._id) !=
|
||||
undefined
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedAlbum(newVal) {
|
||||
if (newVal._id) {
|
||||
if (this.albums.length == 0) {
|
||||
this.albums.push(newVal);
|
||||
}
|
||||
if (!this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.open();
|
||||
window.addEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
this.gotoTrack();
|
||||
} else {
|
||||
if (this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.close();
|
||||
}
|
||||
window.removeEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
AlbumMerge,
|
||||
TrackItem,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#albumViewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
#albumList {
|
||||
overflow-x: overlay;
|
||||
max-width: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
#albumList::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
#header {
|
||||
position: relative;
|
||||
min-width: 280px;
|
||||
width: 456px;
|
||||
background: black;
|
||||
}
|
||||
#header img {
|
||||
align-self: center;
|
||||
}
|
||||
#navigation {
|
||||
width: 456px;
|
||||
overflow: auto;
|
||||
}
|
||||
#stats {
|
||||
z-index: 2;
|
||||
color: var(--white);
|
||||
text-shadow: 0 1px 2px black;
|
||||
line-height: 1.4;
|
||||
background-color: #ffffff40;
|
||||
border-top: 1px solid #ffffff20;
|
||||
border-bottom: 1px solid #00000020;
|
||||
}
|
||||
#trackList {
|
||||
height: 360px;
|
||||
width: 456px;
|
||||
background-color: var(--white);
|
||||
z-index: 1;
|
||||
}
|
||||
.album {
|
||||
transition: transform 0.25s;
|
||||
}
|
||||
.album.focus {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.dummyAlbum {
|
||||
min-width: 160px;
|
||||
}
|
||||
@media (max-width: 480px), (max-height: 480px) {
|
||||
#header {
|
||||
width: 100%;
|
||||
}
|
||||
#trackList {
|
||||
height: initial;
|
||||
width: 100%;
|
||||
}
|
||||
.dummyAlbum {
|
||||
min-width: 128px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
src/components/dialogs/ArtistMerge.vue
Normal file
115
src/components/dialogs/ArtistMerge.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
title="Move all albums..."
|
||||
buttonText="Move"
|
||||
buttonClass="accept"
|
||||
@accept="merge"
|
||||
@closed="closed"
|
||||
:closeOnButtonClick="false"
|
||||
:enableFooterButtons="acceptable"
|
||||
>
|
||||
<div class="flex-row" id="merge-content">
|
||||
<div class="flex-column">
|
||||
<h4 class="ma-left ma-top">From</h4>
|
||||
<ArtistItem class="ma" :item="source" />
|
||||
</div>
|
||||
<awesome-icon id="arrow-icon" icon="arrow-right" />
|
||||
<div class="flex-column">
|
||||
<DropDown class="ma-left ma-top">
|
||||
<input
|
||||
type="search"
|
||||
v-model="search"
|
||||
@search="searchTermChanged"
|
||||
placeholder="Into"
|
||||
/>
|
||||
<template v-slot:dropdown-content>
|
||||
<div class="flex-column">
|
||||
<h3 class="ma" v-if="artists.length == 0">
|
||||
Enter an artist name
|
||||
</h3>
|
||||
<ArtistItem
|
||||
class="ma8"
|
||||
v-for="artist in artists"
|
||||
:key="artist._id"
|
||||
:item="artist"
|
||||
@click="select(artist)"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
<ArtistItem class="ma" :item="target" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
artists: [],
|
||||
search: "",
|
||||
searchTimer: 0,
|
||||
source: {},
|
||||
target: { covers: {} },
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
closed() {
|
||||
this.artists = [];
|
||||
this.source = {};
|
||||
this.target = { covers: {} };
|
||||
this.search = "";
|
||||
clearTimeout(this.searchTimer);
|
||||
},
|
||||
open(source) {
|
||||
this.source = source;
|
||||
this.$refs.dialogWindow.open();
|
||||
},
|
||||
merge() {
|
||||
this.$store
|
||||
.dispatch("artists/move", {
|
||||
source: this.source._id,
|
||||
target: this.target._id,
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch("artists/loadArtist", this.target._id);
|
||||
this.$store.dispatch("artists/selectArtistById", this.target._id);
|
||||
this.$store.dispatch("artists/remove", this.source._id);
|
||||
this.$refs.dialogWindow.close();
|
||||
});
|
||||
},
|
||||
searchTermChanged() {
|
||||
clearTimeout(this.searchTimer);
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.$store.dispatch("artists/filter", this.search).then((result) => {
|
||||
this.artists = result;
|
||||
});
|
||||
}, 250);
|
||||
},
|
||||
select(album) {
|
||||
this.target = album;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
acceptable() {
|
||||
return this.source._id != undefined && this.target._id != undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
width: 256px;
|
||||
}
|
||||
#arrow-icon {
|
||||
align-self: center;
|
||||
}
|
||||
#merge-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
</style>
|
||||
371
src/components/dialogs/ArtistViewer.vue
Normal file
371
src/components/dialogs/ArtistViewer.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
id="dialogWindow"
|
||||
:title="selectedArtist.name"
|
||||
@canceled="closed"
|
||||
:showFooter="false"
|
||||
:showFullscreenButton="true"
|
||||
:disableXscroll="true"
|
||||
:disableYscroll="true"
|
||||
>
|
||||
<div id="artistViewer">
|
||||
<div id="header" class="flex-column">
|
||||
<div id="background" :style="coverBackground" />
|
||||
<awesome-icon
|
||||
icon="star"
|
||||
size="2x"
|
||||
class="favourite ma4"
|
||||
:class="{ active: isFavourite }"
|
||||
@click="toggleFavourite"
|
||||
/>
|
||||
<h1 @dblclick="dblclick" class="hideOnMobileLandscape">
|
||||
{{ selectedArtist.name }}
|
||||
</h1>
|
||||
<span id="stats" class="hideOnMobileLandscape ma-bottom">
|
||||
<b>{{ artist_tracks.length }}</b> Tracks in
|
||||
<b>{{ artist_albums.length }}</b> Albums with a duration of
|
||||
<b>{{ artist_duration }}</b>
|
||||
</span>
|
||||
<div id="albumList" class="flex-row showOnMobile">
|
||||
<AlbumItem
|
||||
class="ma"
|
||||
:class="{ playing: playingAlbumId == album._id }"
|
||||
v-for="album in selectedArtist.albums"
|
||||
:key="album._id"
|
||||
:item="album"
|
||||
@click="scrollToAlbum(album)"
|
||||
@dblclick="playAlbum(album)"
|
||||
/>
|
||||
</div>
|
||||
<div id="navigation" class="flex-row center">
|
||||
<div class="flex-row grow"></div>
|
||||
<div class="flex-row">
|
||||
<button
|
||||
@click="gotoPrevArtist"
|
||||
class="primary ma4"
|
||||
:title="prevArtist.name"
|
||||
:disabled="!prevArtist._id"
|
||||
>
|
||||
<awesome-icon icon="angle-left" class="ma4" />
|
||||
</button>
|
||||
<button
|
||||
@click="gotoNextArtist"
|
||||
class="primary ma4"
|
||||
:title="nextArtist.name"
|
||||
:disabled="!nextArtist._id"
|
||||
>
|
||||
<awesome-icon icon="angle-right" class="ma4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-row grow right center">
|
||||
<DropDown
|
||||
v-if="$store.getters['user/isAdministrator']"
|
||||
class="hideOnMobile"
|
||||
>
|
||||
<button class="flat pa8-left pa8-right">
|
||||
<awesome-icon icon="ellipsis-v" />
|
||||
</button>
|
||||
<template v-slot:dropdown-content>
|
||||
<div>
|
||||
<button @click="uploadNewCover">
|
||||
<awesome-icon icon="image" />Set new Cover...
|
||||
</button>
|
||||
<button @click="resetCover">
|
||||
<awesome-icon icon="eraser" />Reset Cover
|
||||
</button>
|
||||
<hr />
|
||||
<button @click="mergeArtist">
|
||||
<awesome-icon icon="compress-alt" />Merge Artists...
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-row overflow-y">
|
||||
<div id="albumList" class="flex-column hideOnMobile">
|
||||
<AlbumItem
|
||||
class="ma-top ma-left ma-right"
|
||||
:class="{ playing: playingAlbumId == album._id }"
|
||||
v-for="album in selectedArtist.albums"
|
||||
:key="album._id"
|
||||
:item="album"
|
||||
:id="album._id"
|
||||
:ref="album._id"
|
||||
@click="scrollToAlbum(album)"
|
||||
@dblclick="playAlbum(album)"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
id="trackList"
|
||||
class="tracks"
|
||||
:class="{ playing: selectedTrack._id != null }"
|
||||
>
|
||||
<li v-for="track in selectedArtist.tracks" :key="track._id">
|
||||
<TrackItem :track="track" :ref="track._id" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ArtistMerge ref="mergeDialog" />
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ArtistMerge from "./ArtistMerge";
|
||||
import TrackItem from "../Track";
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
if (this.selectedArtist._id) {
|
||||
this.$refs.dialogWindow.open();
|
||||
window.addEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
dblclick() {
|
||||
this.$store.commit("tracks/resetSelectedTrack");
|
||||
this.$store.commit("radios/resetSelectedRadio");
|
||||
this.$store.dispatch("tracks/playContainer", this.selectedArtist);
|
||||
},
|
||||
gotoTrack() {
|
||||
if (this.$route.query.play) {
|
||||
if (!this.selectedTrack._id) {
|
||||
let track = this.selectedArtist.tracks.find(
|
||||
(f) => f._id == this.$route.query.play
|
||||
);
|
||||
if (track) {
|
||||
this.$store.dispatch("tracks/play", track);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
gotoNextArtist() {
|
||||
this.$store.dispatch("artists/gotoNextArtist");
|
||||
},
|
||||
gotoPrevArtist() {
|
||||
this.$store.dispatch("artists/gotoPrevArtist");
|
||||
},
|
||||
closed() {
|
||||
if (
|
||||
window.history.state.back.indexOf("?") == -1 ||
|
||||
window.history.state.back.startsWith("/search")
|
||||
) {
|
||||
this.$router.back();
|
||||
} else {
|
||||
this.$store.dispatch("artists/resetSelectedArtist");
|
||||
}
|
||||
},
|
||||
playAlbum(album) {
|
||||
this.$store.dispatch("tracks/playContainer", album);
|
||||
},
|
||||
keydownListener(e) {
|
||||
if (e.key == "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
this.gotoPrevArtist();
|
||||
}
|
||||
if (e.key == "ArrowRight") {
|
||||
e.preventDefault();
|
||||
this.gotoNextArtist();
|
||||
}
|
||||
},
|
||||
mergeArtist() {
|
||||
this.$refs.mergeDialog.open(this.selectedArtist);
|
||||
},
|
||||
resetCover() {
|
||||
this.$store.dispatch("artists/resetCover", this.selectedArtist);
|
||||
},
|
||||
scrollToAlbum(album) {
|
||||
let track = album.tracks[0];
|
||||
let control = this.$refs[track._id];
|
||||
control[0].scrollFunction(true);
|
||||
control = this.$refs[album._id];
|
||||
control[0].scrollFunction();
|
||||
},
|
||||
toggleFavourite() {
|
||||
this.$store.dispatch("user/toggleFavourite", {
|
||||
itemId: this.selectedArtist._id,
|
||||
type: "artist",
|
||||
});
|
||||
},
|
||||
uploadNewCover() {
|
||||
this.$store.dispatch("artists/uploadNewCover", this.selectedArtist);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
prevArtist: ["artists/prevArtist"],
|
||||
nextArtist: ["artists/nextArtist"],
|
||||
selectedArtist: ["artists/selectedArtist"],
|
||||
selectedTrack: ["tracks/selectedTrack"],
|
||||
favourites: ["user/favourites"],
|
||||
}),
|
||||
cover() {
|
||||
let covers = this.selectedArtist.covers;
|
||||
if (covers.cover512) {
|
||||
return covers.cover512;
|
||||
}
|
||||
return "/static/icons/dummy/artist.svg";
|
||||
},
|
||||
coverBackground() {
|
||||
return "background-image: url('" + this.cover + "')";
|
||||
},
|
||||
artist_albums() {
|
||||
return this.selectedArtist.albums || [];
|
||||
},
|
||||
artist_duration() {
|
||||
let duration = 0;
|
||||
let hours = 0;
|
||||
let minutes = 0;
|
||||
let seconds = 0;
|
||||
|
||||
this.selectedArtist.tracks.forEach((track) => {
|
||||
duration += track.duration;
|
||||
});
|
||||
|
||||
if (duration >= 3600) {
|
||||
hours = parseInt(duration / 3600);
|
||||
duration -= hours * 3600;
|
||||
}
|
||||
|
||||
minutes = parseInt(duration / 60);
|
||||
seconds = parseInt(duration - minutes * 60);
|
||||
return (
|
||||
(hours > 0 ? hours + ":" : "") +
|
||||
(minutes < 10 ? "0" : "") +
|
||||
minutes +
|
||||
":" +
|
||||
(seconds < 10 ? "0" : "") +
|
||||
seconds
|
||||
);
|
||||
},
|
||||
artist_tracks() {
|
||||
return this.selectedArtist.tracks || [];
|
||||
},
|
||||
isFavourite() {
|
||||
return (
|
||||
this.favourites.find((f) => f.itemId == this.selectedArtist._id) !=
|
||||
undefined
|
||||
);
|
||||
},
|
||||
playingAlbumId() {
|
||||
if (this.selectedTrack) {
|
||||
return this.selectedTrack.parent._id;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedArtist(newVal) {
|
||||
if (newVal._id) {
|
||||
if (!this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.open();
|
||||
window.addEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
this.gotoTrack();
|
||||
} else {
|
||||
if (this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.close();
|
||||
}
|
||||
window.removeEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ArtistMerge,
|
||||
TrackItem,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#artistViewer {
|
||||
height: 640px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
h1,
|
||||
#stats {
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
color: var(--white);
|
||||
text-shadow: 0 1px 2px black;
|
||||
}
|
||||
#artistImage {
|
||||
width: 512px;
|
||||
max-height: 256px;
|
||||
}
|
||||
#header {
|
||||
position: relative;
|
||||
background-color: black;
|
||||
width: 760px;
|
||||
max-width: 100%;
|
||||
}
|
||||
#albumList {
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
background-color: var(--white);
|
||||
}
|
||||
#albumList::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
#albumList .album:last-child {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
#navigation {
|
||||
z-index: 2;
|
||||
background-color: #ffffff40;
|
||||
border-top: 1px solid #ffffff20;
|
||||
border-bottom: 1px solid #00000020;
|
||||
}
|
||||
#trackList {
|
||||
z-index: 1;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.album.playing {
|
||||
box-shadow: 0px 6px 12px #000000a0 !important;
|
||||
}
|
||||
|
||||
.dialog-window.fullscreen #artistViewer,
|
||||
.dialog-window.fullscreen #header {
|
||||
width: initial;
|
||||
height: initial;
|
||||
}
|
||||
.dialog-body button {
|
||||
color: var(--darkgray);
|
||||
}
|
||||
.container {
|
||||
flex-grow: 0;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
}
|
||||
|
||||
@media (max-width: 480px), (max-height: 480px) {
|
||||
#artistViewer {
|
||||
height: initial;
|
||||
}
|
||||
#trackList {
|
||||
width: initial;
|
||||
height: initial;
|
||||
}
|
||||
#header {
|
||||
width: initial;
|
||||
}
|
||||
#albumList {
|
||||
background-color: initial;
|
||||
align-self: center;
|
||||
padding-bottom: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 480px) {
|
||||
}
|
||||
</style>
|
||||
262
src/components/dialogs/AudioUpload.vue
Normal file
262
src/components/dialogs/AudioUpload.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
:closeOnButtonClick="false"
|
||||
:closeOnFocusLost="false"
|
||||
:maxSize="true"
|
||||
:enableFooterButtons="uploadable"
|
||||
title="Upload audio files..."
|
||||
buttonText="Upload"
|
||||
buttonClass="success large"
|
||||
@accept="uploadFiles(0)"
|
||||
@closing="closing"
|
||||
>
|
||||
<div class="flex-column grow">
|
||||
<input
|
||||
style="display: none"
|
||||
ref="files"
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
@change="openFiles"
|
||||
multiple
|
||||
/>
|
||||
<ul id="audioUploadFiles">
|
||||
<li v-for="(file, i) in files" :key="i" class="pa-top">
|
||||
<h4 class="ma-left darkgray-text">{{ file.name }}</h4>
|
||||
<div class="flex-row ma">
|
||||
<awesome-icon
|
||||
:icon="file.state == 'uploading' ? 'upload' : 'check'"
|
||||
size="2x"
|
||||
:class="{
|
||||
'success-text': file.state == 'done',
|
||||
'gray-text': file.state == 'ready',
|
||||
'primary-text': file.state == 'uploading',
|
||||
}"
|
||||
/>
|
||||
<input
|
||||
placeholder="artist name"
|
||||
type="text"
|
||||
v-model="file.tags.artist"
|
||||
class="left pa8"
|
||||
/>
|
||||
<input
|
||||
placeholder="album name"
|
||||
type="text"
|
||||
v-model="file.tags.album"
|
||||
class="pa8"
|
||||
/>
|
||||
<input
|
||||
placeholder="track number"
|
||||
title="track number"
|
||||
type="text"
|
||||
v-model="file.tags.track"
|
||||
style="width: 32px"
|
||||
class="right pa8"
|
||||
/>
|
||||
<input
|
||||
placeholder="track title"
|
||||
type="text"
|
||||
v-model="file.tags.title"
|
||||
class="pa8 grow"
|
||||
/>
|
||||
<input
|
||||
placeholder="genre"
|
||||
type="text"
|
||||
v-model="file.tags.genre"
|
||||
class="pa8"
|
||||
/>
|
||||
<input
|
||||
placeholder="year"
|
||||
type="text"
|
||||
v-model="file.tags.year"
|
||||
style="width: 48px"
|
||||
class="right pa8"
|
||||
/>
|
||||
<button
|
||||
class="danger"
|
||||
title="Remove from list"
|
||||
@click="remove(file)"
|
||||
>
|
||||
<awesome-icon icon="minus" />
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="flex-column grow center ma primary-text"
|
||||
id="dropzone"
|
||||
@dragover="dropover"
|
||||
@drop="droped"
|
||||
@click="emitFileClick"
|
||||
>
|
||||
<h2>
|
||||
Drop your files here<br />
|
||||
<awesome-icon icon="plus" size="4x" />
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
import mmreader from "mp3tag.js";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.dialogWindow.open();
|
||||
},
|
||||
closing() {
|
||||
this.files = [];
|
||||
},
|
||||
dropover(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
droped(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let files = [];
|
||||
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
||||
let file = e.dataTransfer.files[i];
|
||||
if (file.type.indexOf("audio/") == 0) {
|
||||
files.push({ file: file, state: "ready" });
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
this.readTags(files, 0);
|
||||
}
|
||||
},
|
||||
emitFileClick() {
|
||||
this.$refs.files.click();
|
||||
},
|
||||
openFiles(e) {
|
||||
let files = [];
|
||||
if (e.srcElement.value) {
|
||||
for (let i = 0; i < e.srcElement.files.length; i++) {
|
||||
let file = e.srcElement.files[i];
|
||||
files.push({ file: file, state: "ready" });
|
||||
}
|
||||
this.readTags(files, 0);
|
||||
}
|
||||
},
|
||||
readTags(files, index) {
|
||||
let fileReader = new FileReader();
|
||||
var file = files[index];
|
||||
fileReader.onload = () => {
|
||||
if (!this.files.find((f) => f.file.name == file.file.name)) {
|
||||
const mp3tag = new mmreader(fileReader.result);
|
||||
mp3tag.read();
|
||||
file.tags = {};
|
||||
file.tags.title = mp3tag.tags.title;
|
||||
file.tags.artist = mp3tag.tags.artist;
|
||||
file.tags.album = mp3tag.tags.album;
|
||||
file.tags.track = mp3tag.tags.track;
|
||||
file.tags.year = mp3tag.tags.year;
|
||||
if (!file.tags.title || file.tags.title.trim() == "") {
|
||||
file.tags.title = file.file.name;
|
||||
}
|
||||
this.files.push(file);
|
||||
}
|
||||
if (files.length > index + 1) {
|
||||
this.readTags(files, ++index);
|
||||
} else {
|
||||
this.files.sort((a, b) => {
|
||||
if (
|
||||
a.tags.artist > b.tags.artist ||
|
||||
(a.tags.artist == b.tags.artist && a.tags.album > b.tags.album) ||
|
||||
(a.tags.artist == b.tags.artist &&
|
||||
a.tags.album == b.tags.album &&
|
||||
a.tags.track > b.tags.track) ||
|
||||
(a.tags.artist == b.tags.artist &&
|
||||
a.tags.album == b.tags.album &&
|
||||
a.tags.track == b.tags.track &&
|
||||
a.tags.title > b.tags.title)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
};
|
||||
fileReader.readAsArrayBuffer(file.file);
|
||||
},
|
||||
remove(file) {
|
||||
this.files.splice(this.files.indexOf(file), 1);
|
||||
},
|
||||
uploadFiles(index = 0) {
|
||||
if (this.files.length > index) {
|
||||
let file = this.files[index];
|
||||
if (file.state == "ready") {
|
||||
file.state = "uploading";
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append("file", file.file);
|
||||
formData.append("track", JSON.stringify(file.tags));
|
||||
|
||||
this.$store
|
||||
.dispatch("tracks/upload", formData)
|
||||
.then(() => {
|
||||
file.state = "done";
|
||||
this.uploadFiles(++index);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
this.$refs.dialogWindow.messageText = err;
|
||||
});
|
||||
} else {
|
||||
this.uploadFiles(++index);
|
||||
}
|
||||
} else {
|
||||
this.$store.dispatch("albums/loadNewest");
|
||||
this.$refs.dialogWindow.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
uploadable() {
|
||||
let has_empty_artist =
|
||||
this.files.find((f) => (f.tags.artist || "").trim() == "") != undefined;
|
||||
let has_empty_album =
|
||||
this.files.find((f) => (f.tags.album || "").trim() == "") != undefined;
|
||||
let has_empty_title =
|
||||
this.files.find((f) => (f.tags.title || "").trim() == "") != undefined;
|
||||
let has_empty_track =
|
||||
this.files.find((f) => (f.tags.track || "").trim() == "") != undefined;
|
||||
return (
|
||||
!has_empty_artist &&
|
||||
!has_empty_album &&
|
||||
!has_empty_title &&
|
||||
!has_empty_track
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#audioUploadFiles input {
|
||||
border: none;
|
||||
font-size: large;
|
||||
}
|
||||
#audioUploadFiles button {
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
border-radius: 16px;
|
||||
}
|
||||
#dropzone {
|
||||
border: 2px dashed var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
113
src/components/dialogs/BoxMerge.vue
Normal file
113
src/components/dialogs/BoxMerge.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
title="Move all videos..."
|
||||
buttonText="Move"
|
||||
buttonClass="accept"
|
||||
@accept="merge"
|
||||
@closed="closed"
|
||||
:closeOnButtonClick="false"
|
||||
:enableFooterButtons="acceptable"
|
||||
>
|
||||
<div class="flex-row" id="merge-content">
|
||||
<div class="flex-column">
|
||||
<h4 class="ma-left ma-top">From</h4>
|
||||
<BoxItem class="ma" :item="source" />
|
||||
</div>
|
||||
<awesome-icon id="arrow-icon" icon="arrow-right" />
|
||||
<div class="flex-column">
|
||||
<DropDown class="ma-left ma-top">
|
||||
<input
|
||||
type="search"
|
||||
v-model="search"
|
||||
@search="searchTermChanged"
|
||||
placeholder="Into"
|
||||
/>
|
||||
<template v-slot:dropdown-content>
|
||||
<div class="flex-column">
|
||||
<h3 class="ma" v-if="boxes.length == 0">Enter an box title</h3>
|
||||
<BoxItem
|
||||
class="ma8"
|
||||
v-for="box in boxes"
|
||||
:key="box._id"
|
||||
:item="box"
|
||||
@click="select(box)"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
<BoxItem class="ma" :item="target" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
boxes: [],
|
||||
search: "",
|
||||
searchTimer: 0,
|
||||
source: {},
|
||||
target: { covers: {} },
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
closed() {
|
||||
this.boxes = [];
|
||||
this.source = {};
|
||||
this.target = { covers: {} };
|
||||
this.search = "";
|
||||
clearTimeout(this.searchTimer);
|
||||
},
|
||||
open(source) {
|
||||
this.source = source;
|
||||
this.$refs.dialogWindow.open();
|
||||
},
|
||||
merge() {
|
||||
this.$store
|
||||
.dispatch("boxes/move", {
|
||||
source: this.source._id,
|
||||
target: this.target._id,
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch("boxes/loadBox", this.target._id);
|
||||
this.$store.dispatch("boxes/selectBoxById", this.target._id);
|
||||
this.$store.dispatch("boxes/remove", this.source._id);
|
||||
this.$refs.dialogWindow.close();
|
||||
});
|
||||
},
|
||||
searchTermChanged() {
|
||||
clearTimeout(this.searchTimer);
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.$store.dispatch("boxes/filter", this.search).then((result) => {
|
||||
this.boxes = result;
|
||||
});
|
||||
}, 250);
|
||||
},
|
||||
select(album) {
|
||||
this.target = album;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
acceptable() {
|
||||
return this.source._id != undefined && this.target._id != undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
width: 128px;
|
||||
}
|
||||
#arrow-icon {
|
||||
align-self: center;
|
||||
}
|
||||
#merge-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
</style>
|
||||
301
src/components/dialogs/BoxViewer.vue
Normal file
301
src/components/dialogs/BoxViewer.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
:title="selectedBox.title"
|
||||
@canceled="closed"
|
||||
:showFooter="false"
|
||||
:closeOnEscape="selectedVideo._id == null"
|
||||
:disableXscroll="true"
|
||||
:disableYscroll="true"
|
||||
>
|
||||
<div id="boxViewer">
|
||||
<div id="header" class="flex-column">
|
||||
<div id="background" :style="coverBackground" />
|
||||
<awesome-icon
|
||||
icon="star"
|
||||
size="2x"
|
||||
class="favourite ma4 z2"
|
||||
:class="{ active: isFavourite }"
|
||||
@click="toggleFavourite"
|
||||
/>
|
||||
<div id="navigation" class="flex-row grow center z1">
|
||||
<img class="glow ma" :src="cover" @dblclick="dblclick" />
|
||||
</div>
|
||||
<div id="stats" class="z1 pa4">
|
||||
<div class="flex-row">
|
||||
<DropDown v-if="$store.getters['user/isAdministrator']">
|
||||
<button
|
||||
class="flat ma4 pa8-left pa8-right"
|
||||
:title="visibility_text"
|
||||
>
|
||||
<awesome-icon :icon="visibility_icon" />
|
||||
</button>
|
||||
<template v-slot:dropdown-content>
|
||||
<div>
|
||||
<button
|
||||
v-for="(item, i) in $store.state.system.lists.visibility"
|
||||
:key="i"
|
||||
@click="setVisibility(item)"
|
||||
>
|
||||
<awesome-icon :icon="getVisibilityIcon(item)" />{{
|
||||
getVisibilityText(item)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
<span class="grow center">
|
||||
<b>{{ selectedBox.title }}</b>
|
||||
<br />
|
||||
<b>{{ box_videos.length }}</b> Videos
|
||||
</span>
|
||||
<DropDown v-if="$store.getters['user/isAdministrator']">
|
||||
<button class="flat ma4 pa8-left pa8-right">
|
||||
<awesome-icon icon="ellipsis-v" />
|
||||
</button>
|
||||
<template v-slot:dropdown-content>
|
||||
<div>
|
||||
<button @click="uploadNewCover">
|
||||
<awesome-icon icon="image" />Set new Cover...
|
||||
</button>
|
||||
<button @click="resetCover">
|
||||
<awesome-icon icon="eraser" />Reset Cover
|
||||
</button>
|
||||
<hr />
|
||||
<button @click="mergeBox">
|
||||
<awesome-icon icon="compress-alt" />Merge Boxes...
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul id="videoList" class="videos">
|
||||
<li v-for="item in selectedBox.videos" :key="item._id">
|
||||
<VideoItem :video="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<BoxMerge ref="mergeDialog" />
|
||||
</DialogBase>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BoxMerge from "./BoxMerge";
|
||||
import VideoItem from "../Video";
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
dblclick() {
|
||||
this.$store.commit("tracks/resetSelectedTrack");
|
||||
this.$store.commit("radios/resetSelectedRadio");
|
||||
this.$store.dispatch("videos/playContainer", this.selectedBox);
|
||||
},
|
||||
gotoVideo() {
|
||||
if (this.$route.query.play) {
|
||||
let video = this.selectedBox.videos.find(
|
||||
(f) => f._id == this.$route.query.play
|
||||
);
|
||||
if (video) {
|
||||
this.$store.dispatch("videos/play", video);
|
||||
}
|
||||
}
|
||||
},
|
||||
gotoNextBox() {
|
||||
this.$store.dispatch("boxes/gotoNextBox");
|
||||
},
|
||||
gotoPrevBox() {
|
||||
this.$store.dispatch("boxes/gotoPrevBox");
|
||||
},
|
||||
closed() {
|
||||
if (
|
||||
window.history.state.back.indexOf("?") == -1 ||
|
||||
window.history.state.back.startsWith("/search")
|
||||
) {
|
||||
this.$router.back();
|
||||
} else {
|
||||
this.$store.dispatch("boxes/resetSelectedBox");
|
||||
}
|
||||
},
|
||||
keydownListener(e) {
|
||||
if (e.key == "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
this.gotoPrevBox();
|
||||
}
|
||||
if (e.key == "ArrowRight") {
|
||||
e.preventDefault();
|
||||
this.gotoNextBox();
|
||||
}
|
||||
},
|
||||
mergeBox() {
|
||||
this.$refs.mergeDialog.open(this.selectedBox);
|
||||
},
|
||||
toggleFavourite() {
|
||||
this.$store.dispatch("user/toggleFavourite", {
|
||||
itemId: this.selectedBox._id,
|
||||
type: "box",
|
||||
});
|
||||
},
|
||||
setVisibility(visibility) {
|
||||
this.selectedBox.visibility = visibility;
|
||||
this.$store.dispatch("boxes/updateBox", this.selectedBox);
|
||||
},
|
||||
uploadNewCover() {
|
||||
this.$store.dispatch("boxes/uploadNewCover", this.selectedBox);
|
||||
},
|
||||
getVisibilityIcon(visibility) {
|
||||
return visibility == "global"
|
||||
? "globe"
|
||||
: visibility == "instance"
|
||||
? "server"
|
||||
: visibility == "hidden"
|
||||
? "eye-slash"
|
||||
: "user";
|
||||
},
|
||||
getVisibilityText(visibility) {
|
||||
return visibility == "global"
|
||||
? "Global"
|
||||
: visibility == "instance"
|
||||
? "On this server"
|
||||
: visibility == "hidden"
|
||||
? "Hide this Box"
|
||||
: "Only for me";
|
||||
},
|
||||
resetCover() {
|
||||
this.$store.dispatch("boxes/resetCover", this.selectedBox);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
prevBox: ["boxes/prevBox"],
|
||||
nextBox: ["boxes/nextBox"],
|
||||
selectedBox: ["boxes/selectedBox"],
|
||||
selectedVideo: ["videos/selectedVideo"],
|
||||
favourites: ["user/favourites"],
|
||||
}),
|
||||
box_videos() {
|
||||
return this.selectedBox.videos || [];
|
||||
},
|
||||
box_year() {
|
||||
return this.selectedBox.year;
|
||||
},
|
||||
coverBackground() {
|
||||
return "background-image: url('" + this.cover + "')";
|
||||
},
|
||||
cover() {
|
||||
let cover = "/static/icons/dummy/box.svg";
|
||||
if (this.selectedBox.covers && this.selectedBox.covers.cover256) {
|
||||
cover = this.selectedBox.covers.cover256;
|
||||
}
|
||||
return cover;
|
||||
},
|
||||
isFavourite() {
|
||||
return (
|
||||
this.favourites.find((f) => f.itemId == this.selectedBox._id) !=
|
||||
undefined
|
||||
);
|
||||
},
|
||||
visibility_icon() {
|
||||
return this.selectedBox.visibility == "global"
|
||||
? "globe"
|
||||
: this.selectedBox.visibility == "instance"
|
||||
? "server"
|
||||
: this.selectedBox.visibility == "hidden"
|
||||
? "eye-slash"
|
||||
: "user";
|
||||
},
|
||||
visibility_text() {
|
||||
return this.selectedBox.visibility == "global"
|
||||
? "Visible for the whole world"
|
||||
: this.selectedBox.visibility == "instance"
|
||||
? "Visible on this instance"
|
||||
: this.selectedBox.visibility == "hidden"
|
||||
? "Hidden for all users"
|
||||
: "Visible only for me";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedBox(newVal) {
|
||||
if (newVal._id) {
|
||||
if (!this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.open();
|
||||
window.addEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
this.gotoVideo();
|
||||
} else {
|
||||
if (this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.close();
|
||||
}
|
||||
window.removeEventListener("keydown", this.keydownListener);
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
BoxMerge,
|
||||
VideoItem,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#boxViewer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#header {
|
||||
position: relative;
|
||||
min-width: 280px;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#videoList {
|
||||
height: 440px;
|
||||
background-color: var(--white);
|
||||
z-index: 1;
|
||||
overflow: overlay;
|
||||
}
|
||||
|
||||
#stats {
|
||||
z-index: 2;
|
||||
color: var(--white);
|
||||
text-shadow: 0 1px 2px black;
|
||||
line-height: 1.4;
|
||||
background-color: #ffffff40;
|
||||
border-top: 1px solid #ffffff20;
|
||||
border-bottom: 1px solid #00000020;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
#boxViewer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px), (max-height: 480px) {
|
||||
#videoList {
|
||||
max-height: initial;
|
||||
height: initial;
|
||||
}
|
||||
.video {
|
||||
width: initial;
|
||||
}
|
||||
|
||||
#navigation img{
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
@media (max-height: 480px) {
|
||||
#navigation {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
src/components/dialogs/DesktopSettings.vue
Normal file
45
src/components/dialogs/DesktopSettings.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<DialogBase ref="dialogWindow" title="Settings" @closed="closed">
|
||||
<div id="settingsBody">
|
||||
<table class="configValues">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<h4>Configuration</h4>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Backend</td>
|
||||
<td>
|
||||
<input v-model.lazy="backend" @change="backendChanged" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "SettingsDialog",
|
||||
data() {
|
||||
return {
|
||||
backend: "",
|
||||
newBackend: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.dialogWindow.open();
|
||||
this.backend = this.$store.state.server;
|
||||
},
|
||||
backendChanged() {
|
||||
this.$store.dispatch("setNewBackend", this.backend);
|
||||
this.newBackend = true;
|
||||
},
|
||||
closed() {
|
||||
if (this.newBackend) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
121
src/components/dialogs/Radios.vue
Normal file
121
src/components/dialogs/Radios.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
title="Radio Stations"
|
||||
:showCloseButton="false"
|
||||
@closed="closed"
|
||||
buttonText="close"
|
||||
>
|
||||
<template v-slot:header-right>
|
||||
<div id="newRadio">
|
||||
<input
|
||||
id="radioName"
|
||||
ref="newName"
|
||||
v-model="newName"
|
||||
type="text"
|
||||
placeholder="Station Name"
|
||||
required
|
||||
pattern=".+"
|
||||
/>
|
||||
<input
|
||||
id="radioUrl"
|
||||
ref="newUrl"
|
||||
v-model="newUrl"
|
||||
type="text"
|
||||
placeholder="Add a stream url"
|
||||
required
|
||||
pattern="https?://.+"
|
||||
@keydown.enter="addRadio"
|
||||
/>
|
||||
<button @click="addRadio" title="Add new Radio Station" class="success">
|
||||
<awesome-icon icon="plus" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
id="radiosBody"
|
||||
class="ma4-top ma4-left ma4-bottom"
|
||||
v-if="$store.getters['radios/collection'].length > 0"
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">Name</th>
|
||||
<th class="maxWidth">Url</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(radio, i) in $store.getters['radios/collection']"
|
||||
:key="i"
|
||||
>
|
||||
<td>
|
||||
<img
|
||||
class="radioCover"
|
||||
:src="radio.cover32"
|
||||
@click="updateCover(radio)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ radio.name }}</td>
|
||||
<td>{{ radio.url }}</td>
|
||||
<td>
|
||||
<button
|
||||
@click="deleteRadio(radio)"
|
||||
title="Remove Radio Station"
|
||||
class="flat"
|
||||
>
|
||||
<awesome-icon icon="trash-alt" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "RadiosDialog",
|
||||
data() {
|
||||
return {
|
||||
newName: "",
|
||||
newUrl: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$store.state.systemDialog = true;
|
||||
this.$refs.dialogWindow.open();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.newName.focus();
|
||||
});
|
||||
},
|
||||
closed() {
|
||||
this.$store.state.systemDialog = false;
|
||||
},
|
||||
isInputValid() {
|
||||
let inputs = document.querySelectorAll("#newRadio input");
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
if (!inputs[i].validity.valid) {
|
||||
inputs[i].focus();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
addRadio() {
|
||||
if (this.isInputValid()) {
|
||||
let newRadio = { name: this.newName, url: this.newUrl };
|
||||
this.$store.dispatch("radios/addRadio", newRadio);
|
||||
}
|
||||
},
|
||||
deleteRadio(radio) {
|
||||
this.$store.dispatch("radios/deleteRadio", radio);
|
||||
},
|
||||
updateCover(radio) {
|
||||
this.$store.dispatch("radios/updateRadio", radio);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
119
src/components/dialogs/ServerSettings.vue
Normal file
119
src/components/dialogs/ServerSettings.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
title="Server Settings"
|
||||
@accept="accept"
|
||||
@closing="closing"
|
||||
:closeOnButtonClick="false"
|
||||
:closeOnFocusLost="false"
|
||||
buttonText="Accept"
|
||||
buttonClass="accept"
|
||||
>
|
||||
<h4 class="ma-top">General</h4>
|
||||
<hr class="ma-right ma-left" />
|
||||
<ul class="ma">
|
||||
<li>
|
||||
<input type="checkbox" id="allowGuests" v-model="allows.guests" />
|
||||
<label for="allowGuests">Allow Guests</label>
|
||||
</li>
|
||||
<li>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowRegistrations"
|
||||
v-model="allows.register"
|
||||
/>
|
||||
<label for="allowRegistrations">Allow Registration</label>
|
||||
</li>
|
||||
</ul>
|
||||
<h4 class="ma-top">Actions</h4>
|
||||
<hr class="ma-right ma-left" />
|
||||
<div><button class="danger ma" @click="resetRedisCache">Reset Redis Cache</button></div>
|
||||
|
||||
<h4 class="ma-top">Accepted Domains</h4>
|
||||
<hr class="ma-right ma-left" />
|
||||
<ul class="ma-top ma-left">
|
||||
<li v-for="(domain, i) in domains.const" :key="i">
|
||||
<span> {{ domain }}</span>
|
||||
</li>
|
||||
<li v-for="(domain, i) in domains.dynamic" :key="i">
|
||||
<span> {{ domain }}</span
|
||||
><button class="flat danger" @click="removeDomain(domain)">
|
||||
<awesome-icon icon="trash-alt" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<input
|
||||
type="url"
|
||||
class="ma"
|
||||
v-model="newDomain"
|
||||
placeholder="Add new domain"
|
||||
@change="addDomain"
|
||||
/>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
allows: { guests: false, register: false },
|
||||
domains: {},
|
||||
newDomain: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
accept() {
|
||||
this.$store.dispatch("saveSystemAllows", this.allows);
|
||||
this.$store.dispatch("saveSystemDomains", this.domains);
|
||||
this.$refs.dialogWindow.close();
|
||||
},
|
||||
addDomain(e) {
|
||||
if (e.srcElement.checkValidity()) {
|
||||
this.domains.dynamic.push(this.newDomain);
|
||||
this.newDomain = "";
|
||||
}
|
||||
},
|
||||
closing() {},
|
||||
open() {
|
||||
this.allows = { ...this.$store.getters.serverConfig.allows };
|
||||
this.$store.dispatch("loadSystemDomains").then((domains) => {
|
||||
this.domains = { ...domains };
|
||||
});
|
||||
this.$refs.dialogWindow.open();
|
||||
},
|
||||
removeDomain(domain) {
|
||||
this.domains.dynamic.splice(this.domains.dynamic.indexOf(domain), 1);
|
||||
},
|
||||
resetRedisCache(){
|
||||
this.$store.dispatch("resetRedisCache");
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
h4 {
|
||||
margin-left: 12px;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
li:not(:last-child) {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
li input {
|
||||
margin-right: 4px;
|
||||
}
|
||||
li span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
li button {
|
||||
opacity: 0.25;
|
||||
}
|
||||
li button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
input[type="url"] {
|
||||
margin-right: 24px;
|
||||
}
|
||||
</style>
|
||||
167
src/components/dialogs/UserProfile.vue
Normal file
167
src/components/dialogs/UserProfile.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
:title="$store.state.user.name + '\'s Profile'"
|
||||
@closed="closed"
|
||||
:showCloseButton="false"
|
||||
:closeOnFocusLost="false"
|
||||
:closeOnEscape="false"
|
||||
>
|
||||
<div id="profileBody" class="ma">
|
||||
<table class="padding">
|
||||
<tr>
|
||||
<td colspan="2"><h4>Set new password</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div id="newUserPass">
|
||||
<input
|
||||
autocomplete="off"
|
||||
type="password"
|
||||
ref="oldPass"
|
||||
v-model="oldPass"
|
||||
placeholder="Current Password"
|
||||
/>
|
||||
|
||||
<input
|
||||
autocomplete="off"
|
||||
type="password"
|
||||
ref="newPass"
|
||||
v-model="newPass"
|
||||
placeholder="New Password"
|
||||
@keydown.enter="changePassword"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
<tr>
|
||||
<td colspan="2"><h4 class="ma-top">Configuration</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Clear history</td>
|
||||
<td>
|
||||
<button @click="clearHistory">clear</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><h4 class="ma-top">Audioquality</h4></td>
|
||||
<td colspan="2"><h4 class="ma-top">Video</h4></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>On Moble Devices</td>
|
||||
<td>
|
||||
<select v-model="$store.getters['user/settings'].mobile_bpm">
|
||||
<option
|
||||
v-for="(item, i) in this.$store.state.system.lists
|
||||
.audio_quality"
|
||||
:key="i"
|
||||
>
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
<span>kBit/s</span>
|
||||
</td>
|
||||
<td>Preferred Language</td>
|
||||
<td class="fillCell">
|
||||
<select v-model="$store.getters['user/settings'].video_lang">
|
||||
<option
|
||||
v-for="(item, i) in this.$store.state.system.lists.video_lang"
|
||||
:key="i"
|
||||
>
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>On Desktop Devices</td>
|
||||
<td>
|
||||
<select v-model="$store.getters['user/settings'].desktop_bpm">
|
||||
<option
|
||||
v-for="(item, i) in this.$store.state.system.lists
|
||||
.audio_quality"
|
||||
:key="i"
|
||||
>
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
<span>kBit/s</span>
|
||||
</td>
|
||||
<td>Quality</td>
|
||||
<td class="fillCell">
|
||||
<select v-model="$store.getters['user/settings'].video_quality">
|
||||
<option
|
||||
v-for="(item, i) in this.$store.state.system.lists
|
||||
.video_quality"
|
||||
:key="i"
|
||||
>
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
oldPass: "",
|
||||
newPass: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
if (!this.loaded) {
|
||||
this.$store.dispatch("system/loadLists");
|
||||
this.$store.state.systemDialog = true;
|
||||
this.loaded = true;
|
||||
}
|
||||
this.$refs.dialogWindow.open();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.oldPass.focus();
|
||||
});
|
||||
},
|
||||
closed() {
|
||||
this.$store.dispatch("user/updateConfig");
|
||||
this.$store.state.systemDialog = false;
|
||||
},
|
||||
changePassword() {
|
||||
console.log("changePassword");
|
||||
let user = { oldPass: this.oldPass, newPass: this.newPass };
|
||||
this.$store
|
||||
.dispatch("user/update", user)
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 202:
|
||||
this.oldPass = "";
|
||||
this.newPass = "";
|
||||
this.$refs.dialogWindow.messageText = "Password changed";
|
||||
this.$refs.dialogWindow.focusButton();
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
switch (e.status) {
|
||||
case 401:
|
||||
this.$refs.dialogWindow.messageText = "Not Authorized";
|
||||
break;
|
||||
case 422:
|
||||
this.$refs.oldPass.focus();
|
||||
this.$refs.oldPass.select();
|
||||
this.$refs.dialogWindow.messageText = "Password is incorrect";
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearHistory() {
|
||||
this.$store.dispatch("user/cleanHistory");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
214
src/components/dialogs/Users.vue
Normal file
214
src/components/dialogs/Users.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
title="Users"
|
||||
:showCloseButton="false"
|
||||
buttonText="Close"
|
||||
@closed="closed"
|
||||
@opened="loadContent"
|
||||
>
|
||||
<template v-slot:header-right>
|
||||
<div id="newUser">
|
||||
<input
|
||||
placeholder="New User"
|
||||
v-model="newUser"
|
||||
ref="newUser"
|
||||
type="text"
|
||||
required
|
||||
pattern=".+"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<input
|
||||
placeholder="Password"
|
||||
v-model="newPass"
|
||||
ref="newPass"
|
||||
type="password"
|
||||
@keydown.enter="validateAndSaveInput"
|
||||
required
|
||||
pattern=".{5,}"
|
||||
/>
|
||||
<button
|
||||
@click="validateAndSaveInput"
|
||||
title="Add new Useraccount"
|
||||
class="success"
|
||||
>
|
||||
<awesome-icon icon="user-plus" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div id="usersBody" class="ma-top ma-left ma-bottom">
|
||||
<table class="padding">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th v-if="$store.getters['user/isAdministrator']">Roles</th>
|
||||
<th>Last Access</th>
|
||||
<th>Change Password</th>
|
||||
<th class="slim" v-if="$store.getters['user/isAdministrator']"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(user, i) in users" :key="i">
|
||||
<td :class="{ me: user._id == me._id }">
|
||||
{{ user.name }}
|
||||
</td>
|
||||
<td v-if="$store.getters['user/isAdministrator']">
|
||||
<DropDown v-if="user._id != me._id" :closeOnClick="false">
|
||||
<button class="flat">
|
||||
<awesome-icon icon="user-cog" />
|
||||
</button>
|
||||
<template v-slot:dropdown-content>
|
||||
<div
|
||||
class="user-role ma4"
|
||||
v-for="(role, i) in lists.user_role"
|
||||
:key="i"
|
||||
>
|
||||
<input
|
||||
:id="role"
|
||||
class="ma4-right"
|
||||
type="checkbox"
|
||||
:checked="user.roles && user.roles.includes(role)"
|
||||
@change="changeRole(user, role)"
|
||||
/>
|
||||
<label :for="role">{{ role }}</label>
|
||||
</div>
|
||||
</template>
|
||||
</DropDown>
|
||||
</td>
|
||||
<td class="right">{{ user.last_access }}</td>
|
||||
<td>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
@keydown.enter="updateUser(user, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td v-if="$store.getters['user/isAdministrator']" class="slim">
|
||||
<button
|
||||
@click="deleteUser(user)"
|
||||
title="Remove Useraccount"
|
||||
class="flat danger"
|
||||
v-if="user._id != me._id"
|
||||
>
|
||||
<awesome-icon icon="trash-alt" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
name: "UsersDialog",
|
||||
data() {
|
||||
return {
|
||||
newUser: "",
|
||||
newPass: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$store.state.systemDialog = true;
|
||||
this.$refs.dialogWindow.open();
|
||||
this.$nextTick(() => {
|
||||
this.$refs.newUser.focus();
|
||||
});
|
||||
},
|
||||
closed() {
|
||||
this.$store.state.systemDialog = false;
|
||||
},
|
||||
loadContent() {
|
||||
this.$store.dispatch("system/loadUsers");
|
||||
},
|
||||
updateUser(user, event) {
|
||||
var newUserPassword = event.srcElement.value;
|
||||
if (newUserPassword.length == 0) {
|
||||
console.log("No Password");
|
||||
return;
|
||||
}
|
||||
user.newPassword = newUserPassword;
|
||||
this.$store.dispatch("system/updateUser", user).then(() => {
|
||||
this.$refs.dialogWindow.messageText =
|
||||
"Password changed for " + user.name;
|
||||
this.$refs.dialogWindow.focusButton();
|
||||
});
|
||||
},
|
||||
deleteUser(user) {
|
||||
this.$store.dispatch("system/deleteUser", user);
|
||||
},
|
||||
validateAndSaveInput() {
|
||||
this.newUser = this.newUser.trim();
|
||||
if (this.newUser.length == 0) {
|
||||
this.$refs.dialogWindow.messageText = "No Username";
|
||||
this.$refs.newUser.focus();
|
||||
return;
|
||||
}
|
||||
if (this.newPass.length == 0) {
|
||||
this.$refs.dialogWindow.messageText = "No Password";
|
||||
this.$refs.newPass.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let newUser = { name: this.newUser, password: this.newPass };
|
||||
this.$store
|
||||
.dispatch("system/addUserIfNotExists", newUser)
|
||||
.then((success) => {
|
||||
if (!success) {
|
||||
this.$refs.dialogWindow.messageText = "Username bereits vergeben";
|
||||
}
|
||||
});
|
||||
},
|
||||
changeRole(user, role) {
|
||||
let i = user.roles.indexOf(role);
|
||||
if (i > -1) {
|
||||
user.roles.splice(i, 1);
|
||||
} else {
|
||||
user.roles.push(role);
|
||||
}
|
||||
delete user.newPassword;
|
||||
this.$store.dispatch("system/updateUser", user);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
users: ["system/users"],
|
||||
lists: ["system/lists"],
|
||||
}),
|
||||
me() {
|
||||
return this.$store.state.user;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#newUser {
|
||||
display: flex;
|
||||
}
|
||||
#usersBody th {
|
||||
text-align: left;
|
||||
}
|
||||
#usersBody td {
|
||||
color: var(--gray);
|
||||
}
|
||||
#usersBody .me {
|
||||
font-weight: bold;
|
||||
}
|
||||
#usersBody table button {
|
||||
opacity: 0.25;
|
||||
}
|
||||
#usersBody table button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
#usersBody .user-role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
td input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
173
src/components/dialogs/VideoScreen.vue
Normal file
173
src/components/dialogs/VideoScreen.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
@closing="closing"
|
||||
:showFooter="false"
|
||||
:showFullscreenButton="true"
|
||||
>
|
||||
<template v-slot:header-right>
|
||||
<select
|
||||
v-if="selectedVideo.tracks && selectedVideo.tracks.length > 1"
|
||||
@change="langChanged"
|
||||
v-model="selectedLang"
|
||||
>
|
||||
<option
|
||||
v-for="(lang, i) in selectedVideo.tracks"
|
||||
:key="i"
|
||||
:value="lang"
|
||||
:title="lang.title"
|
||||
>
|
||||
{{ lang.lang.toUpperCase() }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<video
|
||||
@ended="nextVideo"
|
||||
@timeupdate="timeUpdate"
|
||||
controls
|
||||
style="height: 100%; width: 100%; background: black"
|
||||
ref="videoControl"
|
||||
src
|
||||
></video>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
video: undefined,
|
||||
languages: [],
|
||||
selectedLang: {},
|
||||
preConvert: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
langChanged() {
|
||||
this.video.src =
|
||||
this.$store.getters["videos/getStreamUrl"] + this.langIndex;
|
||||
this.video.play();
|
||||
},
|
||||
playVideo(video) {
|
||||
this.$store.commit("radios/resetSelectedRadio");
|
||||
this.$store.commit("tracks/resetSelectedTrack");
|
||||
this.$refs.dialogWindow.open(video.title);
|
||||
this.preConvert = false;
|
||||
this.languages = [];
|
||||
if (!this.video) {
|
||||
this.$nextTick(() => {
|
||||
this.video = this.$refs.videoControl;
|
||||
this.playStream(video);
|
||||
});
|
||||
} else {
|
||||
this.playStream(video);
|
||||
}
|
||||
},
|
||||
playStream(video) {
|
||||
let lastLang = this.selectedLang.lang;
|
||||
let findLang = video.tracks.find(
|
||||
(f) =>
|
||||
f.lang.toUpperCase() ==
|
||||
(
|
||||
lastLang ||
|
||||
this.$store.getters["user/settings"].video_lang ||
|
||||
"ENG"
|
||||
).toUpperCase()
|
||||
);
|
||||
|
||||
this.selectedLang = findLang || video.tracks[0];
|
||||
|
||||
this.video.src =
|
||||
this.$store.getters["videos/getStreamUrl"] + this.langIndex;
|
||||
this.video.play();
|
||||
|
||||
this.pushHistoryItem();
|
||||
},
|
||||
nextVideo() {
|
||||
this.$store.dispatch("videos/playNextTo", this.selectedVideo);
|
||||
},
|
||||
pushHistoryItem() {
|
||||
let item = {
|
||||
id: this.selectedVideo.parent._id,
|
||||
type: "box",
|
||||
title: this.selectedVideo.parent.title,
|
||||
covers: { cover128: this.selectedVideo.parent.covers.cover128 },
|
||||
};
|
||||
this.$store.dispatch("user/saveHistoryItem", item);
|
||||
this.scaleImage(this.selectedVideo.thumbnail, 0.5, (img) => {
|
||||
item = {
|
||||
id: this.selectedVideo._id,
|
||||
type: "video",
|
||||
title: this.selectedVideo.title,
|
||||
thumbnail: img,
|
||||
parent: { _id: this.selectedVideo.parent._id },
|
||||
};
|
||||
this.$store.dispatch("user/saveHistoryItem", item);
|
||||
});
|
||||
},
|
||||
timeUpdate(event) {
|
||||
let percent = (event.target.currentTime / event.target.duration) * 100;
|
||||
if (percent > 30 && !this.preConvert) {
|
||||
this.preConvert = true;
|
||||
this.$store.dispatch("videos/convertNextTo", {
|
||||
video: this.selectedVideo,
|
||||
langIndex: this.langIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
closing() {
|
||||
this.video = undefined;
|
||||
this.$store.dispatch("videos/resetSelectedVideo");
|
||||
},
|
||||
scaleImage(src, factor, callback) {
|
||||
let img = document.createElement("img");
|
||||
|
||||
img.onload = () => {
|
||||
var canvas = document.createElement("canvas");
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
canvas.width = img.width * factor;
|
||||
canvas.height = img.height * factor;
|
||||
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width * factor,
|
||||
height * factor
|
||||
);
|
||||
|
||||
callback(canvas.toDataURL());
|
||||
};
|
||||
img.src = src;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
selectedVideo() {
|
||||
return this.$store.getters["videos/selectedVideo"];
|
||||
},
|
||||
langIndex() {
|
||||
return this.selectedVideo.tracks.indexOf(this.selectedLang);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedVideo(newVal) {
|
||||
if (newVal._id) {
|
||||
this.playVideo(newVal);
|
||||
} else {
|
||||
if (this.$refs.dialogWindow.visible) {
|
||||
this.$refs.dialogWindow.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
223
src/components/dialogs/VideoUpload.vue
Normal file
223
src/components/dialogs/VideoUpload.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<DialogBase
|
||||
ref="dialogWindow"
|
||||
:closeOnButtonClick="false"
|
||||
:closeOnFocusLost="false"
|
||||
:maxSize="true"
|
||||
:enableFooterButtons="uploadable"
|
||||
title="Upload video files..."
|
||||
buttonText="Upload"
|
||||
buttonClass="success large"
|
||||
@accept="uploadFiles(0)"
|
||||
@closing="closing"
|
||||
>
|
||||
<div class="flex-row grow">
|
||||
<input
|
||||
style="display: none"
|
||||
ref="files"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
@change="openFiles"
|
||||
multiple
|
||||
/>
|
||||
<div class="flex-column" v-if="files.length > 0">
|
||||
<img
|
||||
class="boxCover shadow"
|
||||
:src="coverPreview || 'static/icons/dummy/box.svg'"
|
||||
@click="chooseCover"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="ma"
|
||||
placeholder="box title"
|
||||
v-model="box.title"
|
||||
/>
|
||||
<input type="text" class="ma" placeholder="year" v-model="box.year" />
|
||||
</div>
|
||||
<div class="flex-column grow">
|
||||
<ul id="videoUploadFiles">
|
||||
<li v-for="(file, i) in files" :key="i" class="pa-top">
|
||||
<h4 class="ma-left darkgray-text">{{ file.name }}</h4>
|
||||
<div class="flex-row ma">
|
||||
<awesome-icon
|
||||
:icon="file.state == 'uploading' ? 'upload' : 'check'"
|
||||
size="2x"
|
||||
:class="{
|
||||
'success-text': file.state == 'done',
|
||||
'gray-text': file.state == 'ready',
|
||||
'primary-text': file.state == 'uploading',
|
||||
}"
|
||||
/>
|
||||
<input
|
||||
placeholder="video title"
|
||||
type="text"
|
||||
v-model="file.tags.title"
|
||||
class="grow left pa8"
|
||||
/>
|
||||
<button
|
||||
class="danger"
|
||||
title="Remove from list"
|
||||
@click="remove(file)"
|
||||
>
|
||||
<awesome-icon icon="minus" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="flex-column grow center ma primary-text"
|
||||
id="dropzone"
|
||||
@dragover="dropover"
|
||||
@drop="droped"
|
||||
@click="emitFileClick"
|
||||
>
|
||||
<h2>
|
||||
Drop your files here<br />
|
||||
<awesome-icon icon="plus" size="4x" />
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBase>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
box: { title: "", year: "" },
|
||||
files: [],
|
||||
coverPreview: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.dialogWindow.open();
|
||||
},
|
||||
closing() {
|
||||
this.files = [];
|
||||
},
|
||||
chooseCover() {
|
||||
let me = this;
|
||||
let input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/jpeg, image/png";
|
||||
let reader = new FileReader();
|
||||
input.addEventListener("change", function () {
|
||||
if (input.value) {
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
me.box.cover = input.files[0];
|
||||
}
|
||||
});
|
||||
reader.addEventListener("load", function () {
|
||||
me.coverPreview = this.result;
|
||||
});
|
||||
input.click();
|
||||
},
|
||||
dropover(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
droped(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let files = [];
|
||||
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
||||
let file = e.dataTransfer.files[i];
|
||||
if (file.type.indexOf("video/") == 0) {
|
||||
files.push({ file: file, state: "ready" });
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
this.readFiles(files, 0);
|
||||
}
|
||||
},
|
||||
openFiles(e) {
|
||||
let files = [];
|
||||
if (e.srcElement.value) {
|
||||
for (let i = 0; i < e.srcElement.files.length; i++) {
|
||||
let file = e.srcElement.files[i];
|
||||
files.push({ file: file, state: "ready" });
|
||||
}
|
||||
this.readFiles(files, 0);
|
||||
}
|
||||
},
|
||||
readFiles(files, index) {
|
||||
let fileReader = new FileReader();
|
||||
var file = files[index];
|
||||
fileReader.onload = () => {
|
||||
file.tags = {};
|
||||
file.tags.title = file.file.name;
|
||||
this.files.push(file);
|
||||
if (files.length > index + 1) {
|
||||
this.readFiles(files, ++index);
|
||||
}
|
||||
};
|
||||
fileReader.readAsArrayBuffer(file.file);
|
||||
},
|
||||
emitFileClick() {
|
||||
this.$refs.files.click();
|
||||
},
|
||||
remove(file) {
|
||||
this.files.splice(this.files.indexOf(file), 1);
|
||||
},
|
||||
uploadFiles(index = 0) {
|
||||
if (this.files.length > index) {
|
||||
let file = this.files[index];
|
||||
if (file.state == "ready") {
|
||||
file.state = "uploading";
|
||||
let formData = new FormData();
|
||||
formData.append("file", file.file, "file");
|
||||
if (this.box.cover) {
|
||||
formData.append("file", this.box.cover, "cover");
|
||||
delete this.box.cover;
|
||||
}
|
||||
formData.append("title", file.tags.title || "");
|
||||
formData.append("box", JSON.stringify(this.box));
|
||||
this.$store
|
||||
.dispatch("videos/upload", formData)
|
||||
.then(() => {
|
||||
file.state = "done";
|
||||
this.uploadFiles(++index);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
this.$refs.dialogWindow.messageText = err;
|
||||
});
|
||||
} else {
|
||||
this.uploadFiles(++index);
|
||||
}
|
||||
} else {
|
||||
this.$store.dispatch("boxes/loadNewest");
|
||||
this.$refs.dialogWindow.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
uploadable() {
|
||||
return this.files.length > 0 && this.box.title.trim().length > 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
img {
|
||||
margin: 12px;
|
||||
width: 256px;
|
||||
height: 362px;
|
||||
}
|
||||
input[type="text"] {
|
||||
border: none;
|
||||
font-size: large;
|
||||
}
|
||||
#videoUploadFiles button {
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
border-radius: 16px;
|
||||
}
|
||||
#dropzone {
|
||||
border: 2px dashed var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user