This commit is contained in:
Artem Anufrij
2023-02-08 12:37:55 +01:00
commit 3af8005786
152 changed files with 37333 additions and 0 deletions

289
src/App.vue Normal file
View File

@@ -0,0 +1,289 @@
<template>
<div id="app">
<nav
v-show="$route.path != '/login' && $route.path != '/setup'"
:class="{ slideOverTop: $store.getters.isDialogOpen }"
>
<div>
<router-link to="/" title="Home" class="primary">
<awesome-icon icon="home" />
</router-link>
<router-link to="/albums" title="Albums">
<awesome-icon icon="compact-disc" />
<span class="navigation-title">Albums</span>
</router-link>
<router-link to="/artists" title="Artists">
<awesome-icon icon="user-circle" />
<span class="navigation-title">Artists</span>
</router-link>
<router-link to="/radios" title="Online Radio">
<awesome-icon icon="podcast" />
<span class="navigation-title">Radios</span>
</router-link>
<router-link to="/boxes" title="Boxes">
<awesome-icon icon="film" />
<span class="navigation-title">Videos</span>
</router-link>
<router-link
to="/favourites"
title="Favourites"
v-if="user.favourites.length > 0"
>
<awesome-icon icon="star" />
<span class="navigation-title">Favourites</span>
</router-link>
</div>
<div></div>
<div>
<input
type="search"
@focus="gotoSearchView"
@search="searchTermChanged"
incremental
v-model.lazy="$store.state.search.term"
placeholder="Search…"
autocomplete="one-time-code"
class="hideOnMobile"
/>
<DropDown v-if="user.token">
<template v-slot:default>
<button>
<awesome-icon icon="cog" />
</button>
</template>
<template v-slot:dropdown-content>
<button
v-for="(menuItem, i) in $store.getters.viewMenu"
:key="i"
@click="menuItem.event"
>
<awesome-icon :icon="menuItem.icon" />{{ menuItem.title }}
</button>
<hr v-if="$store.getters.viewMenu.length > 0" />
<button
@click="openDialog('dialogUsers')"
v-if="$store.getters['user/isModerator']"
>
<awesome-icon icon="users" />User management
</button>
<button
@click="openDialog('serverSettings')"
v-if="$store.getters['user/isAdministrator']"
>
<awesome-icon icon="server" />Server settings
</button>
<button
v-if="$store.getters.isElectron"
@click="openDialog('dialogSettings')"
>
<awesome-icon icon="cog" />Settings
</button>
<hr />
<button
@click="openDialog('audioUploader')"
v-if="!$store.getters['user/isGuest']"
>
<awesome-icon icon="music" />
Upload Audio Files
</button>
<button
@click="openDialog('videoUploader')"
v-if="!$store.getters['user/isGuest']"
>
<awesome-icon icon="video" />
Upload Video Files
</button>
<hr />
<button @click="openDialog('dialogUserProfile')">
<awesome-icon icon="user" />Profile
</button>
<button @click="logout">
<awesome-icon icon="sign-out-alt" />Logout [{{ user.name }}]
</button>
</template>
</DropDown>
<button v-else title="Login" @click="logout">
<awesome-icon icon="sign-in-alt" size="2x" />
</button>
</div>
</nav>
<div id="app-body">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<Player ref="player" />
<Users ref="dialogUsers" />
<UserProfile ref="dialogUserProfile" />
<ServerSettings ref="serverSettings" />
<DesktopSettings ref="dialogSettings" />
<VideoScreen />
<AudioUploadDialog ref="audioUploader" />
<VideoUploadDialog ref="videoUploader" />
</div>
</template>
<script>
import VideoScreen from "./components/dialogs/VideoScreen";
import DropDown from "./components/base-components/DropDown";
import Users from "./components/dialogs/Users";
import UserProfile from "./components/dialogs/UserProfile";
import DesktopSettings from "./components/dialogs/DesktopSettings";
import Player from "./components/Player";
import ServerSettings from "./components/dialogs/ServerSettings";
import AudioUploadDialog from "./components/dialogs/AudioUpload";
import VideoUploadDialog from "./components/dialogs/VideoUpload";
import { mapGetters } from "vuex";
export default {
name: "app",
data() {
return {
searchTimer: 0,
timeoutHandler: 0,
};
},
created() {
this.login();
this.$store.dispatch("loadClientConfigs").then(() => {
this.$store.dispatch("system/loadLists");
this.$store.dispatch("loadServerInfo");
this.$store.dispatch("loadSystemSettings");
});
},
mounted() {
document.addEventListener("keydown", (e) => {
switch (e.key) {
case "MediaPlay":
this.$refs.player.togglePlaying();
break;
case "MediaTrackNext":
this.$store.dispatch(
"tracks/playNextTo",
this.$store.getters("tracks/selectedTrack")
);
break;
case "MediaTrackPrevious":
this.$store.dispatch(
"tracks/playPrevTo",
this.$store.getters("tracks/selectedTrack")
);
break;
}
});
},
methods: {
pushMediaInfo(track) {
if ("mediaSession" in navigator && track._id) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.parent.artist_name,
album: track.parent.title,
artwork: [
{
src: track.parent.covers.cover32,
sizes: "32x32",
type: "image/png",
},
{
src: track.parent.covers.cover64,
sizes: "64x64",
type: "image/png",
},
{
src: track.parent.covers.cover128,
sizes: "96x96",
type: "image/png",
},
{
src: track.parent.covers.cover128,
sizes: "128x128",
type: "image/png",
},
{
src: track.parent.covers.cover128,
sizes: "256x256",
type: "image/png",
},
],
});
}
},
gotoSearchView() {
if (this.$route.path != "/search") {
let q = this.$store.getters["search/term"];
if (q != "") {
this.$router.push("/search?q=" + q);
} else {
this.$router.push("/search");
}
}
},
searchTermChanged(e) {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
let v = e.srcElement.value;
if (!v) {
this.$store.commit("search/setTerm", v);
if (this.$route.query.q) {
this.$router.replace("/search");
}
return;
} else if (v == this.$store.getters["search/term"]) {
return;
}
this.$store.commit("search/setTerm", v);
this.$router.replace("/search?q=" + this.$store.getters["search/term"]);
}, 250);
},
openDialog(name) {
this.$refs[name].open();
},
login() {
let hash = window.location.hash.replace("#/", "");
if (!hash.startsWith("login") && !hash.startsWith("setup")) {
let redirect = encodeURIComponent(hash);
if (redirect) {
this.$router.replace({
path: "login",
query: { redirect: redirect },
});
} else {
this.$router.replace("login");
}
}
},
logout() {
this.$store.dispatch("user/logout");
},
},
computed: {
...mapGetters({
selectedTrack: "tracks/selectedTrack",
user: "user/user",
}),
},
watch: {
selectedTrack(newVal) {
if (newVal._id) {
this.pushMediaInfo(newVal);
}
},
},
components: {
Player,
AudioUploadDialog,
DropDown,
ServerSettings,
DesktopSettings,
Users,
UserProfile,
VideoScreen,
VideoUploadDialog,
},
};
</script>

78
src/components/Album.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<div
:id="item._id"
class="container album"
@click="click"
:title="item.title"
v-if="type == 'default'"
>
<img class="albumCover shadow" :src="cover" />
<p class="albumTitle componentTitle">{{ item.title }}</p>
</div>
<div v-else-if="type == 'line'" class="album-line flex-row" @click="click">
<img class="albumCover line shadow" :src="cover" />
<p class="albumTitle line">
<b>{{ item.title }}</b
><br />by<br /><b>{{ item.artist_name }}</b>
</p>
</div>
</template>
<script>
import BaseCollection from "../mixins/BaseCollection";
export default {
name: "AlbumItem",
mixins: [BaseCollection],
props: {
type: {
type: String,
default: "default",
},
},
mounted() {
if (this.$route.query.id == this.item._id && this.item.tracks) {
this.$nextTick(() => {
this.scrollFunction();
this.$store.dispatch("albums/selectAlbum", this.item);
});
}
},
methods: {
click() {
if (this.$route.path != "/albums" && this.$route.path != "/artists") {
this.$store.dispatch("albums/loadAlbum", this.item._id);
this.$router.push("/albums?id=" + this.item._id);
} else {
this.scrollFunction();
}
},
},
computed: {
cover() {
let res = "/static/icons/dummy/album.svg";
if (
this.type == "default" &&
this.item.covers &&
this.item.covers.cover128
) {
res = this.item.covers.cover128;
} else if (
this.type == "line" &&
this.item.covers &&
this.item.covers.cover64
) {
res = this.item.covers.cover64;
}
return res;
},
},
};
</script>
<style scoped>
.albumTitle.line {
max-width: initial;
align-self: center;
margin-left: 8px;
line-height: 1.4;
}
</style>

82
src/components/Artist.vue Normal file
View File

@@ -0,0 +1,82 @@
<template>
<div
:id="item._id"
class="container artist"
@click="click"
:title="item.name"
v-if="type == 'default'"
>
<img class="artistCover shadow" :src="cover" />
<p class="artistName componentTitle">
{{ item.name }}
</p>
</div>
<div v-else-if="type == 'line'" class="album-line flex-row" @click="click">
<img class="artistCover shadow line" :src="cover" />
<p class="artistName line">
<b> {{ item.name }}</b>
</p>
</div>
</template>
<script>
import BaseCollection from "../mixins/BaseCollection";
export default {
name: "ArtistItem",
mixins: [BaseCollection],
props: {
type: {
type: String,
default: "default",
},
},
mounted() {
if (this.$route.query.id == this.item._id && this.item.albums) {
this.$nextTick(() => {
this.scrollFunction();
this.$store.dispatch("artists/selectArtist", this.item);
});
}
},
methods: {
click() {
if (this.$route.path != "/artists") {
this.$store.dispatch("artists/loadArtist", this.item._id);
this.$router.push("/artists?id=" + this.item._id);
} else {
this.scrollFunction();
}
},
},
computed: {
cover() {
let res = "/static/icons/dummy/artist.svg";
if (
this.type == "default" &&
this.item.covers &&
this.item.covers.cover256
) {
res = this.item.covers.cover256;
} else if (
this.type == "line" &&
this.item.covers &&
this.item.covers.cover128
) {
res = this.item.covers.cover128;
}
return res;
},
},
};
</script>
<style scoped>
.artistCover.line {
width: 128px;
height: 64px;
}
.artistName.line {
align-self: center;
margin-left: 8px;
}
</style>

90
src/components/Box.vue Normal file
View File

@@ -0,0 +1,90 @@
<template>
<div
:id="item._id"
class="container box"
@click="click"
:title="title"
v-if="type == 'default'"
>
<img class="boxCover shadow" :src="cover" />
<p class="boxTitle componentTitle">
{{ item.title }}
</p>
</div>
<div v-else-if="type == 'line'" class="flex-row line">
<img class="boxCover line shadow" :src="cover" />
<p class="boxTitle line">
<b>{{ item.title }}</b>
</p>
</div>
</template>
<script>
import BaseCollection from "../mixins/BaseCollection";
export default {
name: "BoxItem",
mixins: [BaseCollection],
props: {
type: {
type: String,
default: "default",
},
},
mounted() {
if (this.$route.query.id == this.item._id && this.item.videos) {
this.$nextTick(() => {
this.scrollFunction();
this.$store.dispatch("boxes/selectBox", this.item);
});
}
},
methods: {
click() {
if (this.$route.path != "/boxes") {
this.$store.dispatch("boxes/loadBox", this.item._id);
this.$router.push("/boxes?id=" + this.item._id);
} else {
this.scrollFunction();
}
},
},
computed: {
cover() {
let res = "/static/icons/dummy/box.svg";
if (
this.type == "default" &&
this.item.covers &&
this.item.covers.cover128
) {
res = this.item.covers.cover128;
} else if (
this.type == "line" &&
this.item.covers &&
this.item.covers.cover64
) {
res = this.item.covers.cover64;
}
return res;
},
title() {
return (
this.item.title +
(this.item.year && this.item.year > 0
? " (" + this.item.year + ")"
: "")
);
},
},
};
</script>
<style scoped>
.boxCover.line {
width: 64px;
height: 90px;
}
.boxTitle.line {
align-self: center;
margin-left: 8px;
}
</style>

416
src/components/Player.vue Normal file
View File

@@ -0,0 +1,416 @@
<template>
<div
id="player"
class="flex-column"
v-show="selectedTrack._id || selectedRadio._id"
>
<input
type="range"
id="slider"
min="0"
max="100"
step="0.1"
v-model="selectedTrack.percent"
@change="slideChanged"
/>
<div id="playerBar" class="flex-row">
<div class="flex-row grow">
<img
class="cover pointer"
:src="
selectedTrack.parent.covers.cover64 ||
selectedRadio.cover64 ||
'/static/icons/dummy/album.svg'
"
:title="selectedTrack.parent.title"
@click="gotoContainer"
/>
<div v-if="selectedTrack._id" class="flex-column">
<b>{{ selectedTrack.title }}</b>
from
<b>{{ selectedTrack.parent.title }}</b>
</div>
</div>
<div id="playerControls" class="flex-row center">
<button
@click="switchShuffle"
title="Shuffle mode"
v-if="selectedTrack._id"
>
<img
src="static/icons/media-shuffle-dark.svg"
v-show="$store.getters['player/shuffle']"
class="small"
/>
<img
src="static/icons/media-consecutive-dark.svg"
v-show="$store.getters['player/shuffle'] == false"
class="small"
/>
</button>
<button @click="prevTrack" title="Back" v-if="selectedTrack._id">
<awesome-icon icon="backward" />
</button>
<button @click="togglePlaying" :title="audio.paused ? 'Play' : 'Pause'">
<awesome-icon icon="play" size="2x" v-if="audio.paused" />
<awesome-icon icon="pause" size="2x" v-else />
</button>
<button @click="nextTrack" title="Forward" v-if="selectedTrack._id">
<awesome-icon icon="forward" />
</button>
<button
@click="switchRepeatType"
title="Repeat mode"
v-if="selectedTrack._id"
>
<img
src="static/icons/media-repeat-dark.svg"
class="small"
v-show="$store.getters['player/repeatType'] == 'all'"
/>
<img
src="static/icons/media-repeat-song-dark.svg"
class="small"
v-show="$store.getters['player/repeatType'] == 'one'"
/>
<img
src="static/icons/media-no-repeat-dark.svg"
class="small"
v-show="$store.getters['player/repeatType'] == 'none'"
/>
</button>
</div>
<div
class="flex-row ma-right hideOnMobilePortrait grow right"
v-show="selectedTrack.title"
>
{{ formatedP }}&nbsp;|&nbsp;{{ formatedD }}
</div>
</div>
<audio
preload="auto"
ref="audioControl"
type="audio/mpeg"
@ended="nextTrack"
@canplay="play"
@playing="playing"
@durationchange="durationChanged"
@timeupdate="timeUpdate"
src
></audio>
</div>
</template>
<script>
export default {
name: "PlayerControl",
data() {
return {
audio: {},
duration: 0,
progress: 0,
interval: 0,
preConvert: false,
};
},
mounted() {
this.$nextTick(() => {
this.audio = this.$refs.audioControl;
});
this.setMediaSession();
},
methods: {
play() {
if (this.audio.paused) {
this.audio.play();
}
},
durationChanged() {
this.duration = this.audio.duration;
},
playing() {
window.clearInterval(this.interval);
this.interval = setInterval(() => {
this.progress = this.audio.currentTime;
this.selectedTrack.percent = (100 / this.duration) * this.progress;
}, 500);
},
audioReset() {
this.audio.pause();
this.audio.src = "";
this.$store.commit("tracks/resetSelectedTrack");
},
slideChanged() {
this.audio.pause();
this.$store.dispatch("tracks/skip");
},
skipToPercent(percent) {
let was_paused = this.audio.paused;
this.audio.pause();
let currentTime = Math.floor((this.duration * percent) / 100);
this.audio.currentTime = currentTime;
this.progress = currentTime;
if (!was_paused) {
this.audio.play();
}
},
playRadio(radio) {
this.$store.commit("tracks/resetSelectedTrack");
this.audio.pause();
this.audio.src = radio.url;
let item = {
id: this.selectedRadio._id,
cover128: this.selectedRadio.cover128,
type: "radio",
};
this.$store.dispatch("user/saveHistoryItem", item);
},
playTrack(track) {
this.preConvert = false;
this.$store.commit("radios/resetSelectedRadio");
let url =
this.$store.getters.server +
"/api/tracks/" +
track._id +
"/stream/" +
this.audioBpm;
this.audio.pause();
this.audio.src = url;
let item = {
id: this.currentTrackParent._id,
type: this.currentTrackParentType,
};
if (item.type == "album") {
item.title = this.currentTrackParent.title;
item.covers = { cover128: this.currentTrackParent.covers.cover128 };
} else {
item.name = this.currentTrackParent.name;
item.covers = { cover256: this.currentTrackParent.covers.cover256 };
}
this.$store.dispatch("user/saveHistoryItem", item);
item = {
id: this.selectedTrack._id,
type: "track",
title: this.selectedTrack.title,
covers: { cover32: this.selectedTrack.parent.covers.cover32 },
parent: {
_id: this.selectedTrack.parent._id,
title: this.selectedTrack.parent.title,
},
};
this.$store.dispatch("user/saveHistoryItem", item);
},
nextTrack() {
if (this.$store.getters["player/repeatType"] == "one") {
this.skipToPercent(0);
this.audio.play();
} else {
this.$store.dispatch("tracks/playNextTo", this.selectedTrack);
}
},
prevTrack() {
this.$store.dispatch("tracks/playPrevTo", this.selectedTrack);
},
togglePlaying() {
if (!this.audio) {
return;
}
if (!this.audio.paused) {
this.audio.pause();
} else if (this.audio.src != "") {
this.audio.play();
}
},
reset() {
window.clearInterval(this.interval);
if (!this.audio.paused) {
this.audio.pause();
}
this.audio.src = "";
},
setMediaSession() {
if ("mediaSession" in navigator) {
let me = this;
navigator.mediaSession.setActionHandler("play", function () {
me.togglePlaying();
});
navigator.mediaSession.setActionHandler("pause", function () {
me.togglePlaying();
});
navigator.mediaSession.setActionHandler("seekto", function (details) {
if (details.fastSeek && "fastSeek" in me.audio) {
me.audio.fastSeek(details.seekTime);
return;
}
me.audio.currentTime = details.seekTime;
});
navigator.mediaSession.setActionHandler("previoustrack", function () {
me.prevTrack();
});
navigator.mediaSession.setActionHandler("nexttrack", function () {
me.nextTrack();
});
}
},
gotoContainer() {
switch (this.selectedTrack.parentType) {
case "album":
this.$router.push("/albums?id=" + this.selectedTrack.parent._id);
break;
case "artist":
this.$router.push(
"/artists?id=" + this.selectedTrack.parent.parent._id
);
break;
}
},
switchShuffle() {
this.$store.dispatch("player/toggleShuffleMode");
this.saveUserSettings();
},
switchRepeatType() {
this.$store.dispatch("player/switchPlayerRepeatMode");
this.saveUserSettings();
},
saveUserSettings() {
this.$store.dispatch("user/savePlayerSettings");
},
timeUpdate(event) {
let percent = (event.target.currentTime / event.target.duration) * 100;
if (percent > 10 && !this.preConvert) {
this.preConvert = true;
this.$store.dispatch("tracks/convertNextTo", {
track: this.selectedTrack,
rate: this.audioBpm,
});
}
},
},
computed: {
selectedTrack() {
return this.$store.getters["tracks/selectedTrack"];
},
skipTo() {
return this.selectedTrack.skipTo;
},
selectedRadio() {
return this.$store.getters["radios/selectedRadio"];
},
currentTrackParent() {
return this.$store.getters["tracks/selectedTrackContainer"];
},
currentTrackParentType() {
let type = "album";
if (
this.selectedTrack.parent.parent &&
this.selectedTrack.parent.parent.tracks
) {
type = "artist";
}
return type;
},
formatedD() {
let m = Math.floor(this.duration / 60);
let s = Math.floor(this.duration - m * 60);
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
},
formatedP() {
let m = Math.floor(this.progress / 60);
let s = Math.floor(this.progress - m * 60);
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
},
requestReplayTrack() {
return this.$store.getters["player/requestReplayTrack"];
},
audioBpm() {
return this.$store.getters["user/settings"].desktop_bpm || "128";
},
},
watch: {
requestReplayTrack() {
this.skipToPercent(0);
},
skipTo(newVal) {
if (newVal) {
this.skipToPercent(newVal);
}
},
selectedRadio(newVal) {
if (newVal._id) {
this.playRadio(newVal);
} else {
this.reset();
}
},
selectedTrack(newVal) {
if (newVal._id) {
this.playTrack(newVal);
} else {
this.reset();
}
},
},
};
</script>
<style scoped>
#player {
background-color: var(--nav);
max-height: 60px;
height: 60px;
z-index: 1001;
box-shadow: 0 0px 4px var(--shadow);
}
#player .cover {
width: 52px;
margin-right: 4px;
}
#playerBar {
overflow: hidden;
max-height: 52px;
}
#playerBar > div {
align-items: center;
}
#playerControls button {
display: flex;
padding: 4px 12px;
align-self: stretch;
border: none;
}
@media (max-width: 480px) {
#player #playerControls {
justify-content: end;
}
}
input[type="range"] {
border: none;
overflow: hidden;
-webkit-appearance: none;
background-color: var(--secondary);
height: 8px;
padding: 0px;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 8px;
-webkit-appearance: none;
color: var(--primary);
}
input[type="range"]::-webkit-slider-thumb {
width: 10px;
-webkit-appearance: none;
height: 8px;
cursor: ew-resize;
background: var(--dark);
box-shadow: -4000px 0 0 4000px var(--primary);
}
</style>

34
src/components/Radio.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<div :title="item.name" class="container radio" @click="clicked">
<img class="radioCover shadow" :src="item.cover128" />
<p
class="radioTitle"
:class="{ selected: item == $store.state.selectedRadio }"
>
{{ item.name }}
</p>
</div>
</template>
<script>
import BaseCollection from "../mixins/BaseCollection";
export default {
name: "RadioItem",
mixins: [BaseCollection],
mounted() {
if (this.$route.query.play == this.item._id) {
this.$store.dispatch("radios/play", this.item);
}
},
methods: {
clicked() {
if (this.$route.path == "/" || this.$route.path == "/search") {
this.$router.push("/radios?play=" + this.item._id);
} else {
this.$store.dispatch("radios/play", this.item);
}
},
},
};
</script>

106
src/components/Track.vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<div
:id="track._id"
:title="track.title"
class="track"
@click="clicked"
:class="{ selected: track._id == selectedTrack._id }"
>
<div class="trackDetails">
<img
v-if="showCover == true"
:src="cover"
:title="track.parent.title"
class="trackCover shadow"
/>
<div class="trackTitle">{{ track.title }}</div>
<div class="trackDuration" v-if="track.duration">{{ duration }}</div>
</div>
</div>
</template>
<script>
export default {
name: "TrackItem",
props: {
track: {
type: Object,
},
showCover: {
type: Boolean,
default: true,
},
},
mounted() {
this.scrollFunction();
},
methods: {
clicked() {
if (this.$route.path == "/" || this.$route.path == "/search") {
this.$store.dispatch("albums/loadAlbum", this.track.parent._id);
this.$router.push(
"/albums?id=" + this.track.parent._id + "&play=" + this.track._id
);
} else {
this.$store.dispatch("tracks/play", this.track);
}
},
scrollFunction(force) {
if (this.track._id != this.selectedTrack._id && force !== true) {
return;
}
let tracklist = document.getElementById("trackList");
if (!tracklist) {
return;
}
let parent = tracklist.getBoundingClientRect();
let element = document.getElementById(this.track._id);
if (!element) {
return;
}
let bounding = element.getBoundingClientRect();
let scrollDown = bounding.top < parent.top;
let scrollUp = bounding.bottom > parent.bottom;
if (scrollDown || force) {
this.$nextTick(() => {
element.scrollIntoView({
block: "start",
});
});
} else if (scrollUp) {
this.$nextTick(() => {
element.scrollIntoView({
block: "end",
});
});
}
},
},
computed: {
duration() {
let min = parseInt(this.track.duration / 60);
let sec = parseInt(this.track.duration - min * 60);
return min + ":" + (sec < 10 ? "0" : "") + sec;
},
selectedTrack() {
return this.$store.getters["tracks/selectedTrack"];
},
cover() {
let cover = this.track.covers ? this.track.covers.cover32 : undefined;
if (!cover) {
cover =
this.track.parent.covers && this.track.parent.covers.cover64
? this.track.parent.covers.cover64
: "/static/icons/dummy/album.svg";
}
return cover;
},
},
watch: {
selectedTrack() {
this.scrollFunction();
},
},
};
</script>

65
src/components/Video.vue Normal file
View File

@@ -0,0 +1,65 @@
<template>
<div
:id="video._id"
class="video"
:title="video.title"
@click="clicked"
:class="{ selected: video == $store.state.selectedVideo }"
>
<img class="videoCover shadow" :src="video.thumbnail" />
<div class="videoTitle componentTitle">{{ video.title }}</div>
</div>
</template>
<script>
export default {
name: "VideoItem",
props: {
video: {
type: Object,
},
},
methods: {
clicked() {
if (this.$route.path == "/" || this.$route.path == "/search") {
this.$store.dispatch("boxes/loadBox", this.video.parent._id);
this.$router.push(
"/boxes?id=" + this.video.parent._id + "&play=" + this.video._id
);
} else {
this.$store.dispatch("videos/play", this.video);
}
},
scrollFunction(video) {
if (video._id != this.video._id) {
return;
}
this.$nextTick(() => {
let parent = document
.getElementById("videoList")
.getBoundingClientRect();
let element = document.getElementById(this.video._id);
let bounding = element.getBoundingClientRect();
let scrollDown = bounding.top < parent.top;
let scrollUp = bounding.bottom > parent.bottom;
if (scrollDown) {
element.scrollIntoView({
block: "start",
behavior: "smooth",
});
} else if (scrollUp) {
element.scrollIntoView({
block: "end",
behavior: "smooth",
});
}
});
},
},
computed: {
selectedVideo() {
return this.$store.getters["selectedVideo"];
},
},
};
</script>

View File

@@ -0,0 +1,496 @@
<template>
<div
ref="dialogBackground"
class="dialog-background"
v-if="visible"
@click="bgClicked"
>
<div
class="dialog-window"
:class="{
fullscreen: isFullscreen,
'max-size': maxSize,
playing: $store.getters['player/isPlaying'],
}"
>
<div
class="dialog-header"
:class="{ hideOnMobile: showHeaderOnMobile == false }"
v-if="showHeader"
@dblclick="headerDoubleClick"
>
<div class="dialog-header-left">
<slot name="header-left" />
</div>
<div class="dialog-header-center">
<h3 v-if="dialogTitle">{{ dialogTitle }}</h3>
<slot name="header-center" />
</div>
<div class="dialog-header-right">
<slot name="header-right" />
<button
class="hideOnMobile"
@click="isFullscreen = !isFullscreen"
v-if="showFullscreenButton"
:title="isFullscreen ? 'Restore' : 'Fullscreen'"
>
<awesome-icon v-if="isFullscreen" icon="compress" />
<awesome-icon v-else icon="expand" />
</button>
<button
@click="cancel"
class="red"
v-if="showCloseButton"
title="Close"
>
<awesome-icon icon="times" />
</button>
</div>
</div>
<div
class="dialog-body"
:class="{
hideXScroll: disableXscroll == true,
hideYScroll: disableYscroll == true,
}"
>
<slot />
<div
class="dialog-body-content"
v-if="dialogContent"
v-html="dialogContent"
/>
</div>
<div class="dialog-footer" v-if="showFooter">
<div class="dialog-footer-left">
<span v-if="messageText" class="dialog-message" :class="messageClass">
<awesome-icon v-if="messageIcon" :icon="messageIcon" />
{{ messageText }}
</span>
<slot name="footer-left" />
</div>
<div class="dialog-footer-center">
<slot name="footer-center" />
</div>
<div>
<slot name="footer-right" />
<div
class="dialog-footer-controls"
v-if="
showFooterButtons &&
(openButtons == null || openButtons.length > 0)
"
>
<button
v-for="(button, i) in dialogButtons"
:key="i"
@click="click('clicked', button.text || button)"
:class="button.class || ''"
:disabled="button.disabled"
:title="button.title || ''"
v-show="button.visible != false"
>
<awesome-icon
v-if="button.icon"
:icon="button.icon"
class="ma-right8"
/>
{{ button.text || button }}
</button>
<button
ref="dialogButton"
@click="click('accept')"
v-if="showFooterButtons && !dialogButtons"
:disabled="!enableFooterButtons"
:class="buttonClass"
>
<awesome-icon
v-if="buttonIcon"
:icon="buttonIcon"
class="ma-right8"
/>
{{ buttonText }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "DialogControl",
props: {
title: { type: String, default: "" },
content: { type: String, default: "" },
closeOnEscape: {
type: Boolean,
default: true,
},
closeOnFocusLost: {
type: Boolean,
default: true,
},
closeOnButtonClick: {
type: Boolean,
default: true,
},
disableXscroll: {
type: Boolean,
default: false,
},
disableYscroll: {
type: Boolean,
default: false,
},
showFooter: {
type: Boolean,
default: true,
},
showHeader: {
type: Boolean,
default: true,
},
showHeaderOnMobile: {
type: Boolean,
default: true,
},
showCloseButton: {
type: Boolean,
default: true,
},
showFooterButtons: {
type: Boolean,
default: true,
},
showFullscreenButton: {
type: Boolean,
default: false,
},
enableFooterButtons: {
type: Boolean,
default: true,
},
buttonText: {
type: String,
default: "Done",
},
buttonClass: {
type: String,
default: "",
},
buttonIcon: {
type: String,
default: "",
},
buttons: {
type: Array,
},
maxSize: {
type: Boolean,
default: false,
},
},
data() {
return {
visible: false,
openTitle: "",
openContent: "",
openButtons: null,
isFullscreen: false,
callback: null,
messageText: "",
messageClass: "",
messageIcon: "",
};
},
methods: {
open(title = null, content = null, buttons = null, callback = null) {
this.visible = true;
if (title) {
this.openTitle = title;
}
if (content) {
this.openContent = content;
}
if (buttons) {
this.openButtons = buttons;
}
if (callback) {
this.callback = callback;
}
window.addEventListener("keydown", this.keydownListener);
this.focusButton();
this.$emit("opened");
},
close() {
this.$emit("closing");
this.visible = false;
window.removeEventListener("keydown", this.keydownListener);
this.$emit("closed");
if (this.callback) {
this.callback();
}
},
cancel() {
this.$emit("canceled");
this.close();
},
focusButton() {
this.$nextTick(() => {
if (this.$refs.dialogButton) {
this.$refs.dialogButton.focus();
}
});
},
click(name, parameter = null) {
this.$emit(name, parameter);
if (this.callback) {
this.callback(parameter || name);
this.callback = undefined;
}
if (this.closeOnButtonClick) {
this.close();
}
},
bgClicked(e) {
if (
e.srcElement == this.$refs.dialogBackground &&
this.closeOnFocusLost
) {
this.cancel();
}
},
headerDoubleClick() {
if (this.showFullscreenButton) {
this.isFullscreen = !this.isFullscreen;
}
},
keydownListener(e) {
if (e.key == "Escape" && this.closeOnEscape) {
e.preventDefault();
this.cancel();
}
},
},
computed: {
dialogTitle() {
return this.openTitle || this.title;
},
dialogButtons() {
return this.openButtons || this.buttons;
},
dialogContent() {
return this.openContent || this.content;
},
},
};
</script>
<style>
.dialog-background {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
background-color: #00000080;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
animation: fadeIn ease 0.15s;
}
.dialog-window {
box-shadow: 0px 8px 32px var(--shadow);
background-color: var(--white);
max-width: 90%;
max-height: 80%;
display: flex;
flex-direction: column;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.dialog-window.max-size {
width: 90%;
height: 90%;
}
.dialog-window.fullscreen {
max-width: initial;
max-height: initial;
width: 100%;
height: 100%;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
.dialog-header {
display: flex;
flex-shrink: 0;
background-color: var(--background);
box-shadow: 0px 1px 4px var(--shadow);
border-top-left-radius: 4px;
border-top-right-radius: 4px;
z-index: 1;
}
.dialog-header h3 {
flex-grow: 1;
margin: 0;
font-size: 0.9rem;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: center;
cursor: default;
}
.dialog-header input,
.dialog-header select {
padding: 0 4px;
border: 0;
border-radius: 0;
background-color: var(--white);
align-self: stretch;
}
.dialog-header button,
.dialog-header a {
background-color: transparent;
border: 0;
border-radius: 16px;
padding: 4px;
margin: 2px;
cursor: pointer;
}
.dialog-header button:hover {
background-color: var(--light-gray);
}
.dialog-header button.red:hover {
background-color: var(--red50);
color: var(--white);
}
.dialog-header button.red svg {
color: var(--red);
}
.dialog-header button.red:hover svg {
color: var(--white);
}
.dialog-header button.blue {
background-color: var(--blue);
}
.dialog-header button.green:hover {
background-color: var(--green);
color: var(--green);
}
.dialog-header button.green svg {
color: var(--green);
}
.dialog-header button.yellow {
background-color: var(--yellow);
}
/* SUCCESS */
.dialog-header button.success svg {
color: var(--success);
}
.dialog-header button.success:hover {
background-color: var(--success);
}
.dialog-header button.success:hover svg {
color: var(--white);
}
.dialog-header button.primary,
.dialog-header a.primary {
background-color: var(--primary);
color: var(--white);
}
.dialog-header button > span,
.dialog-header a > span {
margin-left: 12px;
}
.dialog-header svg {
width: 16px !important;
height: 16px;
}
.dialog-header > div,
.dialog-footer > div {
display: flex;
align-items: center;
}
.dialog-header > .dialog-header-center,
.dialog-footer > .dialog-footer-center {
flex-grow: 1;
justify-content: center;
margin: 0 12px;
}
.dialog-header > .dialog-header-right input,
.dialog-header > .dialog-header-right select {
border-left: 1px solid var(--light-border);
}
.dialog-body {
display: flex;
flex-direction: column;
overflow-y: auto;
flex-grow: 1;
}
.dialog-body.hideXScroll {
overflow-x: hidden;
}
.dialog-body.hideYScroll {
overflow-y: hidden;
}
.dialog-body-content {
margin: 4px;
display: flex;
flex-direction: column;
}
.dialog-footer {
border-top: 1px solid var(--light-border);
display: flex;
flex-shrink: 0;
align-items: center;
background-color: var(--background);
}
.dialog-footer button:disabled {
opacity: 0.5;
}
.dialog-footer .dialog-message {
flex-grow: 1;
display: flex;
align-items: center;
margin-left: 4px;
}
.dialog-footer .dialog-message svg {
margin-right: 4px;
}
.dialog-footer .dialog-footer-controls {
padding: 4px;
display: flex;
}
.dialog-footer .dialog-footer-controls button:not(:last-child) {
margin-right: 4px;
}
@media (max-width: 480px), (max-height: 480px) {
.dialog-window {
max-width: initial;
max-height: initial;
width: 100%;
height: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dialog-window.max-size {
width: 100%;
height: 100%;
}
.dialog-header {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dialog-window.playing {
margin-bottom: 60px;
height: calc(100% - 60px);
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<div class="dropdown">
<div ref="dropdownActivator" class="dropdown-activator" @click="click">
<slot />
</div>
<div v-if="open" class="dropdown-background" ref="dropdownBackground" @click="bgClicked">
<div
ref="dropdownContent"
class="dropdown-content"
:style="'top: ' + top + 'px; left: ' + left + 'px;'"
>
<slot name="dropdown-content" />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
closeOnClick: {
type: Boolean,
default: true
}
},
data() {
return {
open: false,
top: 0,
left: 0
};
},
methods: {
click() {
let a = this.$refs.dropdownActivator.getBoundingClientRect();
let x = a.left;
let y = a.top + a.height;
let h = window.innerHeight;
let w = window.innerWidth;
this.open = !this.open;
if (this.open) {
this.$nextTick(() => {
let b = this.$refs.dropdownContent.getBoundingClientRect();
if (x + b.width > w) {
x -= b.width - a.width;
}
if (y + b.height > h) {
y -= b.height + a.height;
}
this.left = x;
this.top = y;
});
}
},
bgClicked(e) {
if (!this.closeOnClick && e.srcElement != this.$refs.dropdownBackground) {
return;
}
this.open = false;
}
}
};
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="expander">
<div class="expander-header" @click="expanded = !expanded">
<awesome-icon icon="caret-up" size="2x" v-if="expanded" />
<awesome-icon icon="caret-down" size="2x" v-else />
<h3>{{title}}</h3>
</div>
<div class="expander-body" v-show="expanded">
<slot />
</div>
</div>
</template>
<script>
export default {
name:"ExpanderControl",
props: {
title: {
type: String,
default: ""
},
expand: {
type: Boolean,
default: false
}
},
data() {
return {
expanded: false
};
}
};
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div id="message-screen">
<div class="ma-left ma-right">
<div id="message-screen-header">
<img v-if="img" :src="img" />
<awesome-icon v-if="icon" :icon="icon" />
<div>
<h1>{{ title }}</h1>
<h2>{{ subtitle }}</h2>
</div>
</div>
<div id="message-screen-body" v-if="showCommands">
<div
class="message-screen-command"
:class="{ 'margin-image': img || icon }"
v-for="(command, index) in commands"
:key="'Command-' + index"
@click="$emit('commandClicked', command)"
>
<awesome-icon v-if="command.icon" size="2x" :icon="command.icon" />
<img v-if="command.img" :src="command.img" />
<div>
<h3>{{ command.title }}</h3>
<span>{{ command.subtitle }}</span>
</div>
</div>
<ul
class="message-screen-list"
:class="{ 'margin-image': img || icon }"
>
<li
v-for="(item, index) in list"
:key="'ListItem-' + index"
@click="$emit('listClicked', item)"
>
{{ listProperty != "" ? item[listProperty] : item }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
commands: {
type: Array,
},
icon: {
type: String,
default: "",
},
img: {
type: String,
default: "",
},
list: {
type: Array,
},
listProperty: {
type: String,
default: "",
},
subtitle: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
showCommands: {
type: Boolean,
default: true,
},
},
};
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

32
src/main.js Normal file
View File

@@ -0,0 +1,32 @@
import { createApp } from 'vue'
import App from './App.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { fas } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import DialogBase from "./components/base-components/Dialog";
import DropDown from "./components/base-components/DropDown";
import MessageScreen from "./components/base-components/MessageScreen";
import AlbumItem from "./components/Album"
import ArtistItem from "./components/Artist"
import BoxItem from "./components/Box"
library.add(fas)
import store from "./store/index";
import router from './router'
const app = createApp(App);
app.use(store);
app.use(router);
app.component('awesome-icon', FontAwesomeIcon);
app.component('AlbumItem', AlbumItem);
app.component('ArtistItem', ArtistItem);
app.component('BoxItem', BoxItem);
app.component('DialogBase', DialogBase);
app.component('DropDown', DropDown);
app.component('MessageScreen', MessageScreen);
app.mount('#app');

View File

@@ -0,0 +1,28 @@
<script>
export default {
props: {
item: { covers: {} },
},
methods: {
scrollFunction() {
let element = document.getElementById(this.item._id);
let bounding = element.getBoundingClientRect();
let scrollDown = bounding.top < 56;
let scrollUp =
bounding.top + bounding.height >
(window.innerHeight || document.documentElement.clientHeight);
if (scrollDown) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
} else if (scrollUp) {
element.scrollIntoView({
behavior: "smooth",
block: "end",
});
}
},
},
};
</script>

75
src/router.js Normal file
View File

@@ -0,0 +1,75 @@
import { createWebHashHistory, createRouter } from "vue-router";
import LoginView from "./views/Login";
import AlbumsView from "./views/Albums";
import ArtistsView from "./views/Artists";
import RadiosView from "./views/Radios";
import BoxesView from "./views/Boxes";
import SearchView from "./views/Search";
import SetupView from "./views/Setup";
import HomeView from "./views/Home";
import UsersView from "./views/Users";
import FavouritesView from "./views/Favourites"
const routes = [
{
path: "/",
component: HomeView
},
{
path: "/login",
component: LoginView
},
{
path: "/albums",
component: AlbumsView
},
{
path: "/albums/:id",
component: AlbumsView
},
{
path: "/artists",
component: ArtistsView
},
{
path: "/artists/:id",
component: ArtistsView
},
{
path: "/radios",
component: RadiosView
},
{
path: "/boxes",
component: BoxesView
},
{
path: "/boxes/:id",
component: BoxesView
},
{
path: "/search",
component: SearchView
},
{
path: "/setup",
component: SetupView
},
{
path: "/me",
component: UsersView
},
{
path: "/favourites",
component: FavouritesView
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;

91
src/store/actions.js Normal file
View File

@@ -0,0 +1,91 @@
import axios from 'axios'
export default {
checkIfInstanceIsNew(context) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/system/setup").then((res) => {
if (res.status == 200) {
resolve(true);
} else {
resolve(false);
}
});
});
},
loadClientConfigs(context) {
return new Promise((resolve) => {
axios.get("./config.json").then((res) => {
context.state.clientConfig.server = res.data.backend;
context.state.clientConfig.isElectron = navigator.userAgent.toLowerCase().includes("electron");
if (window.location.hostname.includes("localhost") && !context.getters.isElectron) {
context.state.clientConfig.server = res.data.backend_dev;
}
resolve();
});
});
},
loadSystemDomains(context) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/system/domains", context.rootGetters.headers).then((res) => {
context.commit("setSystemConfigDomains", res.data);
resolve(res.data);
});
})
},
loadSystemSettings(context) {
axios.get(context.rootGetters.server + "/api/system").then((res) => {
context.commit("setSystemSettings", res.data);
});
},
loadServerInfo(context) {
axios.get(context.rootGetters.server + "/api/info").then((res) => {
context.commit("setServerInfo", res.data);
});
},
resetViewMenu(context) {
context.commit("resetViewMenu");
},
setNewBackend(context, backend) {
axios.post("/settings", { backend: backend }).then(() => {
context.commit("setServer", backend);
});
},
setViewMenu(context, menuItems) {
context.commit("setViewMenu", menuItems);
},
startScanningMusic(context) {
if (!context.state.serverStatus.scanning_music) {
axios.post(context.rootGetters.server + "/api/scan/music", {}, context.rootGetters.headers).then(() => {
context.state.serverStatus.scanning_music = true;
});
}
},
startScanningVideos(context) {
if (!context.state.serverStatus.scanning_video) {
axios.post(context.rootGetters.server + "/api/scan/video", {}, context.rootGetters.headers).then(() => {
context.state.serverStatus.scanning_video = true;
});
}
},
saveSystemAllows(context, allows) {
axios
.post(context.rootGetters.server + "/api/system", allows, context.rootGetters.headers)
.then((res) => {
if (res.status == 200) {
context.commit("setServerConfigAllows", allows);
}
});
},
saveSystemDomains(context, domains) {
axios
.post(context.rootGetters.server + "/api/system/domains", domains.dynamic, context.rootGetters.headers)
.then((res) => {
if (res.status == 200) {
context.commit("setSystemConfigDomains", domains);
}
});
},
resetRedisCache(context) {
axios.post(context.rootGetters.server + "/api/system/reset/redis", {}, context.rootGetters.headers);
}
}

31
src/store/getters.js Normal file
View File

@@ -0,0 +1,31 @@
import router from '../router'
export default {
headers(state, getters) {
return { headers: { authorization: getters["user/token"] } };
},
server(state) {
return state.clientConfig.server;
},
isElectron(state) {
return state.clientConfig.isElectron;
},
isDialogOpen(state, getters) {
return getters["albums/selectedAlbum"]._id || getters["artists/selectedArtist"]._id || getters["boxes/selectedBox"]._id
},
viewMenu(state, getters) {
return state.viewMenu.filter(item => { return item.roles && getters["user/roles"].find(role => { return item.roles.indexOf(role) > -1 }) });
},
routerPath() {
return router.currentRoute._value.path;
},
routerQuery() {
return router.currentRoute._value.query;
},
serverInfo(state) {
return state.serverInfo;
},
serverConfig(state) {
return state.serverConfig;
}
}

36
src/store/index.js Normal file
View File

@@ -0,0 +1,36 @@
import { createStore } from 'vuex'
import state from './state'
import getters from './getters'
import mutations from './mutations'
import actions from "./actions"
import albums from "./modules/albums/"
import artists from "./modules/artists/"
import boxes from "./modules/boxes/"
import radios from "./modules/radios/"
import tracks from "./modules/tracks/"
import player from "./modules/player"
import user from "./modules/user"
import videos from "./modules/videos"
import system from "./modules/system"
import search from "./modules/search"
export default createStore({
state,
getters,
mutations,
actions,
modules: {
albums,
artists,
boxes,
radios,
player,
tracks,
user,
videos,
system,
search
}
})

View File

@@ -0,0 +1,157 @@
import axios from 'axios'
import router from '../../../router'
export default {
clear(context) {
context.commit("clear");
},
filter(context, term) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/albums/filter/" + term, context.rootGetters.headers).then(res => {
resolve(res.data);
});
})
},
getNextTo(context, album) {
return new Promise(resolve => {
let i = context.getters.collection.indexOf(album);
if (i > -1 && i < context.getters.collection.length - 1) {
resolve(context.getters.collection[++i]);
}
resolve();
});
},
getPrevTo(context, album) {
return new Promise(resolve => {
let i = context.getters.collection.indexOf(album);
if (i > 0) {
resolve(context.getters.collection[--i]);
}
resolve();
})
},
loadAlbums(context, force) {
if ((!context.state.eos || force) && !context.state.loading) {
context.state.loading = true;
axios.get(context.rootGetters.server + "/api/albums/page/" + context.state.page++, context.rootGetters.headers).then((res) => {
context.commit("setAlbums", res.data);
});
}
},
loadFavourites(context) {
axios.get(context.rootGetters.server + "/api/albums/favourites", context.rootGetters.headers).then(res => {
if (res.data.length > 0) {
context.commit("setAlbums", res.data);
}
});
},
loadNewest(context) {
axios.get(context.rootGetters.server + "/api/albums/newest/6", context.rootGetters.headers).then((res) => {
context.commit("setNewest", res.data);
});
},
loadAlbum(context, id) {
context.state.loading = true;
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/albums/" + id, context.rootGetters.headers).then((res) => {
if (res.data != "") {
context.commit("setAlbums", [res.data]);
} else {
context.state.loading = false;
}
resolve(res.data);
})
})
},
resetSelectedAlbum(context) {
context.commit("resetSelectedAlbum");
router.push("/albums");
},
move(context, payload) {
return new Promise((resolve) => {
axios.put(context.rootGetters.server + "/api/albums/" + payload.source + "/move", payload, context.rootGetters.headers).then(res => {
resolve(res.data);
});
})
},
remove(context, id) {
context.commit("remove", id);
},
selectAlbum(context, album) {
if (album.tracks.length == 0) {
context.dispatch("loadAlbum", album._id);
}
context.commit('selectAlbum', album);
if (context.rootGetters.routerQuery.id != album._id) {
let url = "/albums?id=" + album._id;
let track = context.rootGetters["tracks/selectedTrack"];
if (track._id && track.parentType == "album" && track.parent._id == album._id) {
url += "&play=" + track._id
}
router.push(url);
}
context.dispatch("preloads");
},
preloads(context) {
// PRELOAD NEXT AND PREV ALBUM
let next = context.getters.nextAlbum;
if (next._id && next.tracks.length == 0) {
context.dispatch("loadAlbum", next._id);
}
let prev = context.getters.prevAlbum;
if (prev._id && prev.tracks.length == 0) {
context.dispatch("loadAlbum", prev._id);
}
},
selectAlbumById(context, id) {
let album = context.getters.collection.find(item => item._id == id);
if (album) {
context.dispatch("selectAlbum", album);
}
},
gotoPrevAlbum(context) {
let prevAlbum = context.getters.prevAlbum;
if (prevAlbum._id) {
context.dispatch("selectAlbum", prevAlbum);
}
},
gotoNextAlbum(context) {
let nextAlbum = context.getters.nextAlbum;
if (nextAlbum._id) {
context.dispatch("selectAlbum", nextAlbum);
}
},
uploadNewCover(context, album) {
let input = document.createElement('input');
input.type = "file";
input.accept = "image/jpeg, image/png";
input.addEventListener("change", function () {
if (input.value) {
let formData = new FormData();
formData.append("file", input.files[0]);
let h = context.rootGetters.headers;
h.headers["content-type"] = "multipart/form-data";
axios
.put(context.rootGetters.server + "/api/albums/" + album._id + "/cover", formData, context.rootGetters.headers)
.then(res => {
album.covers = res.data;
});
}
});
input.click();
},
resetCover(context, album) {
axios.delete(context.rootGetters.server + "/api/albums/" + album._id + "/cover", context.rootGetters.headers).then(() => {
album.covers = {}
});
},
updateAlbum(context, album) {
let body = {
_id: album._id,
visibility: album.visibility
}
axios.put(context.rootGetters.server + "/api/albums/" + album._id, body, context.rootGetters.headers);
}
}

View File

@@ -0,0 +1,36 @@
export default {
collection(state) {
return state.collection;
},
favourites(state, getters, rootState, rootGetters) {
return state.collection.filter(f => rootGetters["user/favourites"].map(m => m.itemId).indexOf(f._id) > -1);
},
nextAlbum(state) {
let currentIndex = state.collection.indexOf(state.selectedAlbum);
let nextAlbum = {};
if (state.collection.length > currentIndex + 1) {
nextAlbum = state.collection[currentIndex + 1];
}
return nextAlbum;
},
prevAlbum(state) {
let currentIndex = state.collection.indexOf(state.selectedAlbum);
let prevAlbum = {};
if (currentIndex > 0) {
prevAlbum = state.collection[currentIndex - 1];
}
return prevAlbum;
},
selectedAlbum(state) {
return state.selectedAlbum;
},
loading(state) {
return state.loading;
},
eos(state) {
return state.eos;
},
newest(state) {
return state.newest;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,79 @@
export default {
clear(state) {
state.collection = [];
state.newest = [];
state.loading = false;
state.eos = false;
state.page = 1;
},
remove(state, id) {
let album = state.collection.find(f => f._id == id);
if (album) {
let i = state.collection.indexOf(album);
state.collection.splice(i, 1);
}
},
resetSelectedAlbum(state) {
if (state.selectedAlbum._id)
state.selectedAlbum = { tracks: [], covers: {} };
},
selectAlbum(state, album) {
if (state.selectedAlbum._id != album._id) {
state.selectedAlbum = album;
}
},
setAlbums(state, albums) {
if (albums.length == 0) {
state.eos = true;
state.loading = false;
if (state.page > 1) {
state.page--;
}
return;
}
albums.forEach(album => {
let existsAlbum = state.collection.find(f => f._id == album._id);
if (!existsAlbum) {
let item = state.collection.find((item) => {
if (item.artist_name > album.artist_name
|| item.artist_name == album.artist_name && item.year > album.year
|| item.artist_name == album.artist_name && item.year == album.year && item.title > album.title) {
return item;
}
})
if (!album.covers) {
album.covers = {};
}
if (item) {
let index = state.collection.indexOf(item);
state.collection.splice(index, 0, album);
} else {
state.collection.push(album);
}
album.tracks.forEach((track) => {
track.parent = album;
track.parentType = "album"
});
} else if (existsAlbum && album.tracks.length > 0) {
existsAlbum.covers = album.covers || {};
existsAlbum.tracks = album.tracks;
existsAlbum.tracks.forEach((track) => {
track.parent = existsAlbum;
track.parentType = "album"
});
}
});
state.loading = false;
},
setNewest(state, albums) {
albums.forEach(album => {
if (!album.covers) {
album.covers = {};
}
});
state.newest = albums;
}
}

View File

@@ -0,0 +1,8 @@
export default {
collection: [],
newest: [],
selectedAlbum: { tracks: [], covers: {} },
page: 1,
loading: false,
eos: false
}

View File

@@ -0,0 +1,123 @@
import axios from 'axios'
import router from '../../../router'
export default {
clear(context) {
context.commit("clear");
},
filter(context, term) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/artists/filter/" + term, context.rootGetters.headers).then(res => {
resolve(res.data);
});
})
},
loadArtists(context, force) {
if ((!context.state.eos || force) && !context.state.loading) {
context.state.loading = true;
axios.get(context.rootGetters.server + "/api/artists/page/" + context.state.page++, context.rootGetters.headers).then((res) => {
context.commit("setArtists", res.data);
});
}
},
loadArtist(context, id) {
context.state.loading = true;
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/artists/" + id, context.rootGetters.headers).then((res) => {
if (res.data != "") {
context.commit("setArtists", [res.data]);
} else {
context.state.loading = false;
}
resolve(res.data);
});
});
},
loadFavourites(context) {
axios.get(context.rootGetters.server + "/api/artists/favourites", context.rootGetters.headers).then(res => {
if (res.data.length > 0) {
context.commit("setArtists", res.data);
}
});
},
selectArtist(context, artist) {
if (artist.albums.length == 0) {
context.dispatch("loadArtist", artist._id)
}
context.commit('selectArtist', artist);
if (context.rootGetters.routerQuery.id != artist._id) {
let url = "/artists?id=" + artist._id;
let track = context.rootGetters["tracks/selectedTrack"];
if (track._id && track.parentType == "artist" && track.parent.parent._id == artist._id) {
url += "&play=" + track._id
}
router.push(url);
}
// PRELOAD NEXT AND PREV ARTIST
let next = context.getters.nextArtist;
if (next._id && next.albums.length == 0) {
context.dispatch("loadArtist", next._id);
}
let prev = context.getters.prevArtist;
if (prev._id && prev.albums.length == 0) {
context.dispatch("loadArtist", prev._id);
}
},
selectArtistById(context, id) {
let artist = context.getters.collection.find(item => item._id == id);
if (artist && artist._id != context.getters.selectedArtist._id) {
context.dispatch("selectArtist", artist);
}
},
resetSelectedArtist(context) {
context.commit("resetSelectedArtist");
router.push("/artists");
},
move(context, payload) {
return new Promise((resolve) => {
axios.put(context.rootGetters.server + "/api/artists/" + payload.source + "/move", payload, context.rootGetters.headers).then(res => {
resolve(res.data);
});
})
},
remove(context, id) {
context.commit("remove", id);
},
gotoPrevArtist(context) {
let prevArtist = context.getters.prevArtist;
if (prevArtist._id) {
context.dispatch("selectArtist", prevArtist);
}
},
gotoNextArtist(context) {
let nextArtist = context.getters.nextArtist;
if (nextArtist._id) {
context.dispatch("selectArtist", nextArtist);
}
},
uploadNewCover(context, artist) {
let input = document.createElement('input');
input.type = "file";
input.accept = "image/jpeg, image/png";
input.addEventListener("change", function () {
if (input.value) {
let formData = new FormData();
formData.append("file", input.files[0]);
let h = context.rootGetters.headers;
h.headers["content-type"] = "multipart/form-data";
axios
.put(context.rootGetters.server + "/api/artists/" + artist._id + "/cover", formData, context.rootGetters.headers)
.then(res => {
artist.covers = res.data;
});
}
});
input.click();
},
resetCover(context, artist) {
axios.delete(context.rootGetters.server + "/api/artists/" + artist._id + "/cover", context.rootGetters.headers).then(() => {
artist.covers = {};
});
},
}

View File

@@ -0,0 +1,33 @@
export default {
collection(state) {
return state.collection;
},
favourites(state, getters, rootState, rootGetters) {
return state.collection.filter(f => rootGetters["user/favourites"].map(m => m.itemId).indexOf(f._id) > -1);
},
prevArtist(state) {
let currentIndex = state.collection.indexOf(state.selectedArtist);
let prevArtist = {};
if (currentIndex > 0) {
prevArtist = state.collection[currentIndex - 1];
}
return prevArtist;
},
nextArtist(state) {
let currentIndex = state.collection.indexOf(state.selectedArtist);
let nextArtist = {};
if (state.collection.length > currentIndex + 1) {
nextArtist = state.collection[currentIndex + 1];
}
return nextArtist;
},
selectedArtist(state) {
return state.selectedArtist;
},
loading(state) {
return state.loading;
},
eos(state) {
return state.eos;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,80 @@
export default {
clear(state) {
state.collection = [];
state.loading = false;
state.eos = false;
state.page = 1;
},
remove(state, id) {
let artist = state.collection.find(f => f._id == id);
if (artist) {
let i = state.collection.indexOf(artist);
state.collection.splice(i, 1);
}
},
resetSelectedArtist(state) {
if (state.selectedArtist._id)
state.selectedArtist = { albums: [], tracks: [], covers: {} };
},
setArtists(state, artists) {
if (artists.length == 0) {
state.eos = true;
state.loading = false;
if (state.page > 1) {
state.page--;
}
return;
}
artists.forEach(artist => {
let existsArtist = state.collection.find(f => f._id == artist._id);
if (!existsArtist) {
let item = state.collection.find((item) => {
if (item.name > artist.name) {
return item;
}
})
if (!artist.covers) {
artist.covers = {};
}
if (item) {
let index = state.collection.indexOf(item);
state.collection.splice(index, 0, artist);
} else {
state.collection.push(artist);
}
artist.albums.forEach((album) => {
album.parent = artist;
if (!album.covers) {
album.covers = {};
}
album.tracks.forEach((track) => {
track.parent = album;
track.parentType = "artist"
artist.tracks.push(track);
});
});
} else if (existsArtist && artist.albums.length > 0) {
existsArtist.tracks = [];
existsArtist.covers = artist.covers || {};
existsArtist.albums = artist.albums;
existsArtist.albums.forEach((album) => {
album.parent = existsArtist;
album.tracks.forEach((track) => {
track.parent = album;
track.parentType = "artist"
existsArtist.tracks.push(track);
});
});
}
});
state.loading = false;
},
selectArtist(state, artist) {
if (state.selectedArtist._id != artist._id) {
state.selectedArtist = artist;
}
},
}

View File

@@ -0,0 +1,7 @@
export default {
collection: [],
selectedArtist: { artists: [], tracks: [], covers: {} },
page: 1,
loading: false,
eos: false
}

View File

@@ -0,0 +1,133 @@
import axios from 'axios'
import router from '../../../router'
export default {
clear(context) {
context.commit("clear");
},
filter(context, term) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/boxes/filter/" + term, context.rootGetters.headers).then(res => {
resolve(res.data);
});
})
},
loadBoxes(context, force) {
if ((!context.state.eos || force) && !context.state.loading) {
context.state.loading = true;
axios.get(context.rootGetters.server + "/api/boxes/page/" + context.state.page++, context.rootGetters.headers).then((res) => {
context.commit("setBoxes", res.data);
});
}
},
loadFavourites(context) {
axios.get(context.rootGetters.server + "/api/boxes/favourites", context.rootGetters.headers).then(res => {
if (res.data.length > 0) {
context.commit("setBoxes", res.data);
}
});
},
loadNewest(context) {
axios.get(context.rootGetters.server + "/api/boxes/newest/6", context.rootGetters.headers).then((res) => {
context.commit("setNewest", res.data);
});
},
loadBox(context, id) {
context.state.loading = true;
axios.get(context.rootGetters.server + "/api/boxes/" + id, context.rootGetters.headers).then((res) => {
if (res.data != "") {
context.commit("setBoxes", [res.data]);
} else {
context.state.loading = false;
}
});
},
resetSelectedBox(context) {
context.commit("resetSelectedBox");
router.push("/boxes");
},
selectBox(context, box) {
if (box.videos.length == 0) {
context.dispatch("loadBox", box._id)
}
context.commit('selectBox', box);
if (!context.rootGetters.routerQuery || context.rootGetters.routerQuery.id != box._id) {
router.push("/boxes?id=" + box._id);
}
context.dispatch("preload")
},
preload(context) {
// PRELOAD NEXT AND PREV ALBUM
let next = context.getters.nextBox;
if (next._id && next.videos.length == 0) {
context.dispatch("loadBox", next._id);
}
let prev = context.getters.prevBox;
if (prev._id && prev.videos.length == 0) {
context.dispatch("loadBox", prev._id);
}
},
selectBoxById(context, id) {
let box = context.getters.collection.find(item => item._id == id);
if (box) {
context.dispatch("selectBox", box);
}
},
gotoPrevBox(context) {
let prevBox = context.getters.prevBox;
if (prevBox._id) {
context.dispatch("selectBox", prevBox);
}
},
move(context, payload) {
return new Promise((resolve) => {
axios
.put(context.rootGetters.server + "/api/boxes/" + payload.source + "/move", payload, context.rootGetters.headers)
.then(res => {
resolve(res.data);
});
})
},
remove(context, id) {
context.commit("remove", id);
},
gotoNextBox(context) {
let nextBox = context.getters.nextBox;
if (nextBox._id) {
context.dispatch("selectBox", nextBox);
}
},
updateBox(context, box) {
let body = {
_id: box._id,
visibility: box.visibility
}
axios.put(context.rootGetters.server + "/api/boxes/" + box._id, body, context.rootGetters.headers);
},
uploadNewCover(context, box) {
let input = document.createElement('input');
input.type = "file";
input.accept = "image/jpeg, image/png";
input.addEventListener("change", function () {
if (input.value) {
let formData = new FormData();
formData.append("file", input.files[0]);
let h = context.rootGetters.headers;
h.headers["content-type"] = "multipart/form-data";
axios
.put(context.rootGetters.server + "/api/boxes/" + box._id + "/cover", formData, context.rootGetters.headers)
.then(res => {
box.covers = res.data;
});
}
});
input.click();
},
resetCover(context, box) {
axios.delete(context.rootGetters.server + "/api/boxes/" + box._id + "/cover", context.rootGetters.headers).then(() => {
box.covers = {}
});
},
}

View File

@@ -0,0 +1,36 @@
export default {
collection(state) {
return state.collection;
},
favourites(state, getters, rootState, rootGetters) {
return state.collection.filter(f => rootGetters["user/favourites"].map(m => m.itemId).indexOf(f._id) > -1);
},
prevBox(state) {
let currentIndex = state.collection.indexOf(state.selectedBox);
let prevBox = {};
if (currentIndex > 0) {
prevBox = state.collection[currentIndex - 1];
}
return prevBox;
},
nextBox(state) {
let currentIndex = state.collection.indexOf(state.selectedBox);
let nextBox = {};
if (state.collection.length > currentIndex + 1) {
nextBox = state.collection[currentIndex + 1];
}
return nextBox;
},
selectedBox(state) {
return state.selectedBox;
},
loading(state) {
return state.loading;
},
eos(state) {
return state.eos;
},
newest(state) {
return state.newest;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,74 @@
export default {
clear(state) {
state.collection = [];
state.newest = [];
state.loading = false;
state.eos = false;
state.page = 1;
},
remove(state, id) {
let box = state.collection.find(f => f._id == id);
if (box) {
let i = state.collection.indexOf(box);
state.collection.splice(i, 1);
}
},
resetSelectedBox(state) {
if (state.selectedBox._id)
state.selectedBox = { videos: [], covers: {} };
},
setBoxes(state, boxes) {
if (boxes.length == 0) {
state.eos = true;
state.loading = false;
if (state.page > 1) {
state.page--;
}
return;
}
boxes.forEach(box => {
let existsBox = state.collection.find(f => f._id == box._id);
if (!existsBox) {
let item = state.collection.find((item) => {
if (item.title > box.title) {
return item;
}
})
if (!box.covers) {
box.covers = {}
}
if (item) {
let index = state.collection.indexOf(box);
state.collection.splice(index, 0, box);
} else {
state.collection.push(box);
}
box.videos.forEach((video) => {
video.parent = box;
});
} else if (existsBox && box.videos.length > 0) {
existsBox.covers = box.covers || {};
existsBox.videos = box.videos;
existsBox.videos.forEach((video) => {
video.parent = existsBox;
});
}
});
state.loading = false;
},
selectBox(state, box) {
if (state.selectedBox._id != box._id) {
state.selectedBox = box;
}
},
setNewest(state, boxes) {
boxes.forEach(box => {
if (!box.covers) {
box.covers = {};
}
});
state.newest = boxes;
}
}

View File

@@ -0,0 +1,8 @@
export default {
collection: [],
newest: [],
selectedBox: { videos: [], covers: {} },
page: 1,
loading: false,
eos: false
}

View File

@@ -0,0 +1,33 @@
export default {
switchPlayerRepeatMode(context) {
switch (context.state.repeatType) {
case "all":
context.state.repeatType = "one";
break;
case "one":
context.state.repeatType = "none";
break;
default:
context.state.repeatType = "all";
break;
}
},
setRepeatType(context, type) {
context.state.repeatType = type;
},
setRequestReplayTrack(context) {
context.commit("requestReplayTrack");
},
toggleShuffleMode(context) {
context.commit("toggleShuffleMode");
let container = context.rootGetters["tracks/selectedTrackContainer"];
let currentTrack = context.rootGetters["tracks/selectedTrack"];
if (context.getters.shuffle && currentTrack._id) {
this.dispatch("tracks/shuffle", { container });
}
else if (container.shuffledTracks) {
delete container.shuffledTracks;
}
},
}

View File

@@ -0,0 +1,14 @@
export default {
repeatType(state) {
return state.repeatType;
},
shuffle(state) {
return state.shuffle;
},
isPlaying(state, getters, rootState, rootGetters) {
return rootGetters["tracks/selectedTrack"]._id !== undefined || rootGetters["radios/selectedRadio"]._id !== undefined;
},
requestReplayTrack(state) {
return state.requestReplayTrack;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,8 @@
export default {
toggleShuffleMode(state) {
state.shuffle = !state.shuffle;
},
requestReplayTrack(state) {
state.requestReplayTrack = !state.requestReplayTrack;
}
}

View File

@@ -0,0 +1,5 @@
export default {
repeatType: "none",
shuffle: false,
requestReplayTrack: false
}

View File

@@ -0,0 +1,56 @@
import axios from 'axios'
import router from '../../../router'
export default {
clear(context) {
context.commit("clear");
},
loadRadios(context, force) {
if (context.state.collection.length == 0 || force) {
axios.get(context.rootGetters.server + "/api/radios", context.rootGetters.headers).then((res) => {
context.commit("setRadios", res.data);
});
}
},
play(context, radio) {
context.commit("selectRadio", radio);
if (!context.rootGetters.routerQuery || context.rootGetters.routerQuery.play != radio._id) {
router.push("/radios?play=" + radio._id);
}
},
resetSelectedRadio(context) {
context.commit("resetSelectedRadio");
router.push("/radios");
},
addRadio(context, radio) {
axios.post(context.rootGetters.server + "/api/radios", radio, context.rootGetters.headers).then(() => {
context.dispatch("loadRadios", true);
});
},
deleteRadio(context, radio) {
axios.delete(context.rootGetters.server + "/api/radios?id=" + radio._id, context.rootGetters.headers).then(() => {
context.dispatch("loadRadios", true);
});
},
updateRadio(context, radio) {
let input = document.createElement('input');
input.type = "file";
input.accept = "image/jpeg, image/png";
input.addEventListener("change", function () {
if (input.value) {
let formData = new FormData();
formData.append("file", input.files[0]);
let h = context.rootGetters.headers;
h.headers["content-type"] = "multipart/form-data";
axios.put(context.rootGetters.server + "/api/radios/" + radio._id + "/cover", formData, h).then((res) => {
radio.cover32 = res.data.cover32;
radio.cover64 = res.data.cover64;
radio.cover128 = res.data.cover128;
context.dispatch("loadRadios", true);
});
}
});
input.click();
}
}

View File

@@ -0,0 +1,8 @@
export default {
collection(state) {
return state.collection;
},
selectedRadio(state) {
return state.selectedRadio;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,22 @@
export default {
clear(state) {
state.collection = [];
state.loading = false;
state.eos = false;
state.page = 1;
},
resetSelectedRadio(state) {
if (!state.selectedRadio._id) {
return;
}
state.selectedRadio = {};
},
selectRadio(state, radio) {
if (state.selectedRadio._id != radio._id) {
state.selectedRadio = radio;
}
},
setRadios(state, radios) {
state.collection = radios;
},
}

View File

@@ -0,0 +1,4 @@
export default {
collection: [],
selectedRadio: {},
}

View File

@@ -0,0 +1,11 @@
import axios from 'axios'
import router from '../../../router'
export default {
search(context, q) {
axios.get(context.rootGetters.server + "/api/search/" + q, context.rootGetters.headers).then((res) => {
context.commit("setResult", res.data);
});
router.push("/search?q=" + q);
},
}

View File

@@ -0,0 +1,8 @@
export default {
collection(state) {
return state.collection;
},
term(state){
return state.term;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,16 @@
export default {
setResult(state, result) {
result.forEach(item => {
if (!item.covers) {
item.covers = {}
}
});
state.collection = result || [];
},
setTerm(state, term) {
state.term = term.trim();
if (state.term == "") {
state.collection = [];
}
}
}

View File

@@ -0,0 +1,4 @@
export default {
collection: [],
term: ""
}

View File

@@ -0,0 +1,73 @@
import axios from 'axios'
import router from '../../../router'
export default {
addUser(context, newUser) {
return new Promise((resolve) => {
axios.post(context.rootGetters.server + "/api/user", newUser, context.rootGetters.headers).then(() => {
context.dispatch("loadUsers");
resolve();
});
})
},
addUserIfNotExists(context, newUser) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/user/" + newUser.name + "/exists", context.rootGetters.headers).then((res) => {
if (res.data.exists) {
resolve(false);
} else {
context.dispatch("addUser", newUser).then(() => {
resolve(true)
});
}
});
});
},
deleteUser(context, user) {
axios.delete(context.rootGetters.server + "/api/user?id=" + user._id, context.rootGetters.headers).then((res) => {
if (res.data.length > 0) {
context.commit("setUsers", res.data);
} else {
window.location.href = "/system/setup";
}
}).catch(err => {
console.log(err.response.status);
});
},
updateUser(context, user) {
return new Promise((resolve) => {
axios.put(context.rootGetters.server + "/api/user/", user, context.rootGetters.headers).then(() => {
resolve();
});
});
},
loadUsers(context) {
axios.get(context.rootGetters.server + "/api/user", context.rootGetters.headers).then((res) => {
context.commit("setUsers", res.data);
});
},
loadRoles(context) {
return new Promise((resolve) => {
axios.get(context.rootGetters.server + "/api/settings/user/roles", context.rootGetters.headers).then((res) => {
context.commit("setRoles", res.data);
resolve();
});
});
},
loadLists(context) {
axios.get(context.rootGetters.server + "/api/settings/lists", context.rootGetters.headers).then((res) => {
context.state.lists = res.data;
});
},
createInstanceAccess(context, user) {
axios
.post(context.rootGetters.server + "/api/system/setup", user)
.then((res) => {
localStorage.setItem("token", res.data.token);
router.replace("login");
})
.catch((err) => {
console.log(err);
});
}
}

View File

@@ -0,0 +1,8 @@
export default {
users(state) {
return state.users
},
lists(state) {
return state.lists;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,8 @@
export default {
setUsers(state, users) {
state.users = users;
},
setRoles(state, roles) {
state.roles = roles;
}
}

View File

@@ -0,0 +1,4 @@
export default {
users: [],
lists: {}
}

View File

@@ -0,0 +1,136 @@
import axios from 'axios';
import router from '../../../router'
export default {
convertNextTo(context, payload) {
let next = getNextTrack(context, payload.track);
if (next && next._id != payload.track._id) {
axios.put(context.rootGetters.server + "/api/tracks/" + next._id + "/convert/" + payload.rate, context.rootGetters.headers);
}
},
play(context, track) {
track.skipTo = 0;
track.percent = 0;
context.commit("selectTrack", track);
if (context.rootGetters.routerQuery.id == context.getters.selectedTrackContainer._id && context.rootGetters.routerQuery.play != track._id) {
switch (track.parentType) {
case "album":
router.replace("/albums?id=" + track.parent._id + "&play=" + track._id);
break;
case "artist":
router.replace("/artists?id=" + track.parent.parent._id + "&play=" + track._id);
break;
}
}
if (context.rootGetters["player/shuffle"]) {
let container = context.getters.selectedTrackContainer;
if (!container.shuffledTracks) {
context.dispatch("shuffle", { container, track });
}
}
},
playContainer(context, container) {
if (context.rootGetters["player/shuffle"]) {
context.dispatch("shuffle", { container }).then(() => {
context.dispatch("play", container.shuffledTracks[0]);
});
} else {
context.dispatch("play", container.tracks[0]);
}
},
playNextTo(context, track) {
let next = getNextTrack(context, track);
if (next) {
context.dispatch("play", next);
} else {
context.commit("resetSelectedTrack");
}
},
playPrevTo(context, track) {
let currentTime = track.duration / 100 * track.percent;
if (currentTime > 3) {
this.dispatch("player/setRequestReplayTrack");
} else {
let prev = getPrevTrack(context, track);
if (prev) {
context.dispatch("play", prev);
} else {
context.commit("resetSelectedTrack");
}
}
},
skip(context) {
context.commit("skip");
},
loadMostListened(context) {
axios.get(context.rootGetters.server + "/api/tracks/most_listened", context.rootGetters.headers)
.then((res) => {
context.commit("setMostListened", res.data);
});
},
upload(context, form) {
let h = context.rootGetters.headers;
h.headers["content-type"] = "multipart/form-data";
return new Promise((resolve, reject) => {
axios
.post(context.rootGetters.server + "/api/tracks", form, h)
.then(() => {
resolve();
})
.catch((err) => {
reject(err);
});
});
},
shuffle(context, payload) {
let container = payload.container;
let currentTrack = payload.track
return new Promise((resolve) => {
let shuffledTracks = [...container.tracks];
let j, x, i;
for (i = shuffledTracks.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i + 1));
x = shuffledTracks[i];
shuffledTracks[i] = shuffledTracks[j];
shuffledTracks[j] = x;
}
if (currentTrack) {
let currentIndex = shuffledTracks.indexOf(currentTrack);
if (currentIndex > 0) {
let tmp = shuffledTracks[currentIndex];
shuffledTracks[currentIndex] = shuffledTracks[0];
shuffledTracks[0] = tmp;
currentIndex = shuffledTracks.indexOf(currentTrack);
}
}
container.shuffledTracks = shuffledTracks;
resolve();
});
}
}
function getNextTrack(context, track) {
if (context.rootGetters["player/repeatType"] == "one") {
return track;
}
let container = context.getters.selectedTrackContainer
let tracks = container.shuffledTracks || container.tracks;
let currentIndex = tracks.indexOf(track)
if (currentIndex < tracks.length - 1) {
return tracks[currentIndex + 1];
} else if (context.rootGetters["player/repeatType"] == "all") {
return tracks[0];
}
return undefined
}
function getPrevTrack(context, track) {
let container = context.getters.selectedTrackContainer
let tracks = container.shuffledTracks || container.tracks;
let currentIndex = tracks.indexOf(track)
if (currentIndex > 0) {
return tracks[currentIndex - 1];
}
return undefined;
}

View File

@@ -0,0 +1,14 @@
export default {
selectedTrack(state) {
return state.selectedTrack;
},
selectedTrackContainer(state) {
if (state.selectedTrack.parent.parent && state.selectedTrack.parent.parent.tracks) {
return state.selectedTrack.parent.parent
}
return state.selectedTrack.parent;
},
mostListened(state) {
return state.mostListened;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,17 @@
export default {
selectTrack(state, track) {
state.selectedTrack = track;
},
resetSelectedTrack(state) {
if (!state.selectedTrack._id) {
return;
}
state.selectedTrack = { title: "", parent: { title: "", covers: {} } };
},
skip(state) {
state.selectedTrack.skipTo = state.selectedTrack.percent;
},
setMostListened(state, tracks) {
state.mostListened = tracks;
}
}

View File

@@ -0,0 +1,6 @@
export default {
selectedTrack: {
title: "", parent: { title: "", covers: {} }
},
mostListened: []
}

View File

@@ -0,0 +1,125 @@
import axios from 'axios'
import router from '../../../router'
export default {
cleanHistory(context) {
axios.delete(context.rootGetters.server + "/api/user/history", context.rootGetters.headers).then(() => {
context.getters["user/history"] = [];
});
},
load(context) {
return new Promise((resolve, reject) => {
axios.get(context.rootGetters.server + "/api/user/login", context.rootGetters.headers).then((res) => {
context.commit("load", res.data);
context.rootState["player"].shuffle = res.data.player.shuffle;
context.rootState["player"].repeatType = res.data.player.repeat;
resolve();
}).catch(err => {
context.commit("resetToken");
reject(err);
});
});
},
login(context, user) {
return new Promise((resolve, reject) => {
axios.post(context.rootGetters.server + "/api/user/login", {
username: user.username,
password: user.password,
})
.then((res) => {
context.commit("load", res.data);
context.dispatch("setToken", res.data.token).then(() => {
resolve();
});
})
.catch((err) => {
context.commit("resetToken");
reject(err.response);
});
});
},
logout(context) {
context.commit("resetToken");
localStorage.setItem("token", "");
router.push("/login")
this.dispatch("albums/clear");
this.dispatch("artists/clear");
this.dispatch("boxes/clear");
this.dispatch("radios/clear");
this.commit("tracks/resetSelectedTrack");
this.commit("radios/resetSelectedRadio");
this.commit("videos/resetSelectedVideo");
},
toggleFavourite(context, item) {
let fav = context.state.favourites;
let f = fav.find(f => f.itemId == item.itemId);
if (f == undefined) {
axios.post(context.rootGetters.server + "/api/user/favourites", item, context.rootGetters.headers);
fav.push(item);
} else {
axios.delete(context.rootGetters.server + "/api/user/favourites?itemId=" + item.itemId, context.rootGetters.headers);
fav.splice(fav.indexOf(f), 1);
}
},
setToken(context, token) {
return new Promise((resolve) => {
localStorage.setItem("token", token);
context.commit("setToken", token);
resolve();
});
},
saveHistoryItem(context, item) {
if (context.state._id == -1) {
return;
}
axios
.post(context.rootGetters.server + "/api/user/history", item, context.rootGetters.headers)
.then((res) => {
context.commit("setHistory", res.data);
});
},
savePlayerSettings(context) {
let body = {
repeat: context.rootGetters["player/repeatType"],
shuffle: context.rootGetters["player/shuffle"]
};
axios
.put(context.rootGetters.server + "/api/user/settings", body, context.rootGetters.headers);
},
update(context, user) {
return new Promise((resolve, reject) => {
axios.post(
context.rootGetters.server + "/api/user/update",
{
oldPassword: user.oldPass,
newPassword: user.newPass,
},
context.rootGetters.headers
).then((res) => {
resolve(res);
}).catch((err) => {
reject(err);
});
});
},
updateConfig(context) {
axios.post(context.rootGetters.server + "/api/user/update",
{
mobile_bpm: context.getters["settings"].mobile_bpm,
desktop_bpm: context.getters["settings"].desktop_bpm,
video_lang: context.getters["settings"].video_lang,
video_quality: context.getters["settings"].video_quality,
fullname: context.getters.user.fullname,
},
context.rootGetters.headers
);
},
useGuestAccount(context) {
return new Promise((resolve) => {
context.commit("setGuestAccount");
resolve();
})
}
}

View File

@@ -0,0 +1,29 @@
export default {
favourites(state) {
return state.favourites;
},
history(state) {
return state.history;
},
roles(state) {
return state.roles;
},
settings(state) {
return state.settings;
},
token(state) {
return state.token;
},
user(state) {
return state;
},
isGuest(state) {
return state._id == -1;
},
isAdministrator(state) {
return state.roles.includes("admin");
},
isModerator(state) {
return state.roles.includes("admin") || state.roles.includes("moderator");
},
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,41 @@
export default {
load(state, user) {
user.history.forEach(item => {
item._id = item.id;
})
state.settings.mobile_bpm = user.mobile_bpm;
state.settings.desktop_bpm = user.desktop_bpm;
state.settings.video_lang = user.video_lang;
state.settings.video_quality = user.video_quality;
state.name = user.name;
state.roles = user.roles;
state.favourites = user.favourites;
state.history = user.history;
state._id = user._id;
},
resetToken(state) {
state.token = "";
},
setToken(state, token) {
state.token = token;
},
setHistory(state, history) {
history.forEach(item => {
item._id = item.id;
});
state.history = history;
},
setSettings(state, settings) {
state.settings = settings;
},
setGuestAccount(state) {
state.settings.mobile_bpm = 64;
state.settings.desktop_bpm = 128;
state.settings.video_lang = "ENG";
state.settings.video_quality = "realtime";
state.name = 'Guest';
state.roles = ['guest'];
state.history = [];
state._id = "-1";
}
}

View File

@@ -0,0 +1,13 @@
export default {
favourites: [],
history: [],
roles: [],
token: "",
name: "",
settings: {
mobile_bpm: 0,
desktop_bpm: 0,
video_lang: "ENG",
video_quality: "480"
}
}

View File

@@ -0,0 +1,59 @@
import axios from 'axios'
import router from '../../../router'
export default {
play(context, video) {
context.commit("selectVideo", video);
if (context.rootGetters.routerQuery.play != video._id) {
router.push("/boxes?id=" + video.parent._id + "&play=" + video._id);
}
},
playContainer(context, container) {
context.dispatch("play", container.videos[0]);
},
playNextTo(context, video) {
context.commit("resetSelectedVideo");
let currentIndex = video.parent.videos.indexOf(video);
if (currentIndex < video.parent.videos.length - 1) {
context.dispatch("play", video.parent.videos[currentIndex + 1]);
}
},
convertNextTo(context, payload) {
let currentIndex = payload.video.parent.videos.indexOf(payload.video);
if (currentIndex < payload.video.parent.videos.length - 1) {
let nextVideo = payload.video.parent.videos[currentIndex + 1]
axios.get(context.rootGetters.server + "/api/videos/" + nextVideo._id + "/convert/" + (context.rootGetters["user/settings"].video_quality || "realtime") + "/" + payload.langIndex).then(() => {
console.log("Pre Convert started for: " + nextVideo.title);
});
}
},
resetSelectedVideo(context) {
if (!context.getters.selectedVideo._id) {
return;
}
let box_id = context.getters.selectedVideo.parent._id;
context.commit("resetSelectedVideo");
if (context.rootGetters.routerQuery.play)
router.push("/boxes?id=" + box_id);
},
upload(context, form) {
let h = context.rootGetters.headers;
h.headers["content-type"] = "multipart/form-data";
return new Promise((resolve, reject) => {
axios
.post(context.rootGetters.server + "/api/videos", form, h)
.then(() => {
resolve();
})
.catch((err) => {
reject(err);
});
});
},
loadMostViewed(context) {
axios.get(context.rootGetters.server + "/api/videos/most_viewed", context.rootGetters.headers)
.then((res) => {
context.commit("setMostViewed", res.data);
});
},
}

View File

@@ -0,0 +1,11 @@
export default {
selectedVideo(state) {
return state.selectedVideo;
},
getStreamUrl(state, getters, rootState, rootGetters) {
return rootGetters.server + "/api/videos/" + state.selectedVideo._id + "/stream/" + (rootGetters["user/settings"].video_quality || "realtime") + "/"
},
mostViewed(state) {
return state.mostViewed;
}
}

View File

@@ -0,0 +1,12 @@
import state from './state.js';
import getters from './getters.js';
import mutations from './mutations.js';
import actions from './actions.js';
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@@ -0,0 +1,14 @@
export default {
selectVideo(state, video) {
state.selectedVideo = video;
},
resetSelectedVideo(state) {
if (!state.selectedVideo._id) {
return;
}
state.selectedVideo = { tracks: [] };
},
setMostViewed(state, tracks) {
state.mostViewed = tracks;
}
}

View File

@@ -0,0 +1,4 @@
export default {
selectedVideo: { tracks: [] },
mostViewed: []
}

29
src/store/mutations.js Normal file
View File

@@ -0,0 +1,29 @@
export default {
resetViewMenu(state) {
state.viewMenu = [];
},
searchFilter(state, term) {
state.searchFilter = term;
},
setServer(state, server) {
state.server = server;
},
setSystemSettings(state, payload) {
state.serverConfig.allows = payload;
},
setViewMenu(state, menuItems) {
state.viewMenu = menuItems || [];
},
setServerConfig(state, config) {
state.serverConfig = config;
},
setServerConfigAllows(state, allows) {
state.serverConfig.allows = allows;
},
setSystemConfigDomains(state, domains) {
state.serverConfig.domains = domains;
},
setServerInfo(state, info) {
state.serverInfo = info;
}
}

20
src/store/state.js Normal file
View File

@@ -0,0 +1,20 @@
export default {
serverStatus: { scanning_music: false, scanning_video: false },
searchFilter: "",
systemDialog: false,
viewMenu: [],
clientConfig: {
server: "none",
isElectron: false
},
serverConfig: { allows: {}, domains: [] },
serverInfo: {
stats: {
albums: 0,
tracks: 0,
videos: 0,
users: 0
}
}
}

139
src/views/Albums.vue Normal file
View File

@@ -0,0 +1,139 @@
<template>
<div class="flex-column" style="max-height: 100%; height: 100%">
<MessageScreen
title="Still no Audio content"
subtitle="You still don't have any Audio content on your instance"
icon="music"
:commands="messageCommands"
@commandClicked="sync"
:showCommands="$store.getters['user/isAdministrator']"
v-if="albums.length == 0 && !loading"
/>
<AlbumViewer />
<div ref="albums" id="albums" @scroll="loadNextPage" @resize="loadNextPage">
<AlbumItem
class="ma"
v-for="album in albums"
:key="album._id"
:item="album"
@click="openAlbum(album)"
/>
<div id="albumsLoadingControl" class="loadingItem" v-if="!eos">
Loading next albums...
</div>
</div>
</div>
</template>
<script>
import AlbumViewer from "../components/dialogs/AlbumViewer";
import { mapGetters } from "vuex";
export default {
name: "AlbumsView",
data() {
return {
scrollPosition: 0,
messageCommands: [
{
title: "Scan for Audio files",
subtitle: "Scann your server for audio files…",
icon: "sync",
command: "scan",
},
],
viewMenu: [
{
title: "Synchronize Music",
icon: "sync-alt",
roles: ["admin"],
event: this.sync,
},
],
};
},
mounted() {
if (this.$route.query.id) {
this.$store.dispatch("albums/loadAlbum", this.$route.query.id);
} else {
this.loadNextPage(true);
}
this.$store.dispatch("setViewMenu", this.viewMenu);
},
computed: {
...mapGetters({
albums: ["albums/collection"],
loading: ["albums/loading"],
eos: ["albums/eos"],
}),
},
methods: {
openAlbum(album) {
this.$store.dispatch("albums/selectAlbum", album);
},
loadNextPage(force) {
this.scrollPosition = this.$refs.albums.scrollTop;
if ((!this.eos && this.isLoadingControlVisible()) || force === true) {
this.$store.dispatch("albums/loadAlbums", force);
}
},
isLoadingControlVisible() {
let element = document.getElementById("albumsLoadingControl");
if (!element) {
return false;
}
let bounding = element.getBoundingClientRect();
let vVisible =
bounding.top - 256 <=
(window.innerHeight || document.documentElement.clientHeight) &&
bounding.top > -256;
return vVisible;
},
sync() {
this.$store.dispatch("startScanningMusic");
setTimeout(() => {
this.loadNextPage(true);
}, 3000);
},
},
watch: {
loading(newVal) {
if (!newVal) {
this.$nextTick(() => {
this.loadNextPage();
if (this.$store.getters["albums/selectedAlbum"]._id) {
this.$store.dispatch("albums/preloads");
}
});
}
},
"$route.path": function (newVal) {
if (newVal == "/albums") {
this.$nextTick(() => {
this.$refs.albums.scrollTop = this.scrollPosition;
this.$store.dispatch("setViewMenu", this.viewMenu);
if (!this.loading) {
this.loadNextPage();
}
});
}
},
"$route.query": function (newVal) {
if (this.$route.path == "/albums") {
if (newVal.id) {
if (this.$store.getters["albums/selectedAlbum"]._id != newVal.id) {
this.$store.dispatch("albums/selectAlbumById", newVal.id);
}
} else {
this.$store.commit("albums/resetSelectedAlbum");
}
} else {
this.$store.commit("albums/resetSelectedAlbum");
}
},
},
components: {
AlbumViewer,
},
};
</script>

149
src/views/Artists.vue Normal file
View File

@@ -0,0 +1,149 @@
<template>
<div class="flex-column" style="max-height: 100%; height: 100%">
<MessageScreen
title="Still no Audio content"
subtitle="You still don't have any Audio content on your instance"
icon="music"
:commands="messageCommands"
@commandClicked="sync"
:showCommands="$store.getters['user/isAdministrator']"
v-if="artists.length == 0 && !loading"
/>
<ArtistViewer />
<div
ref="artists"
id="artists"
@scroll="loadNextPage"
@resize="loadNextPage"
>
<ArtistItem
class="ma"
v-for="artist in artists"
:key="artist._id"
:item="artist"
@click="openArtist(artist)"
/>
<div
id="artistsLoadingControl"
class="loadingItem"
@click="loadNextPage"
v-if="!eos"
>
Loading next artists...
</div>
</div>
</div>
</template>
<script>
import ArtistViewer from "../components/dialogs/ArtistViewer";
import { mapGetters } from "vuex";
export default {
name: "ArtistsView",
data() {
return {
scrollPosition: 0,
messageCommands: [
{
title: "Scan for Audio files",
subtitle: "Scann your server for audio files…",
icon: "sync",
command: "scan",
},
],
viewMenu: [
{
title: "Synchronize Music",
icon: "sync-alt",
roles: ["admin"],
event: this.sync,
},
],
};
},
mounted() {
if (this.$route.query.id) {
this.$store.dispatch("artists/loadArtist", this.$route.query.id);
} else {
this.loadNextPage(true);
}
this.$store.dispatch("setViewMenu", this.viewMenu);
},
computed: {
...mapGetters({
artists: ["artists/collection"],
loading: ["artists/loading"],
eos: ["artists/eos"],
}),
},
methods: {
openArtist(artist) {
this.$store.dispatch("artists/selectArtist", artist);
},
loadNextPage(force) {
this.scrollPosition = this.$refs.artists.scrollTop;
if ((!this.eos && this.isLoadingControlVisible()) || force === true) {
this.$store.dispatch("artists/loadArtists", force);
this.$store.dispatch("setViewMenu", this.viewMenu);
}
},
isLoadingControlVisible() {
let element = document.getElementById("artistsLoadingControl");
if (!element) {
return false;
}
let bounding = element.getBoundingClientRect();
let vVisible =
bounding.top - 256 <=
(window.innerHeight || document.documentElement.clientHeight) &&
bounding.top > -256;
return vVisible;
},
sync() {
this.$store.dispatch("startScanningMusic");
setTimeout(() => {
this.loadNextPage(true);
}, 3000);
},
},
watch: {
loading(newVal) {
if (!newVal) {
this.$nextTick(() => {
this.loadNextPage();
});
}
},
"$route.path": function (newVal) {
if (newVal == "/artists") {
this.$nextTick(() => {
this.$refs.artists.scrollTop = this.scrollPosition;
this.$store.dispatch("setViewMenu", this.viewMenu);
if (!this.loading) {
this.loadNextPage();
}
});
}
},
"$route.query": function (newVal) {
if (this.$route.path == "/artists") {
if (newVal.id) {
if (this.$store.getters["artists/selectedArtist"]._id != newVal.id) {
this.$store.dispatch("artists/selectArtistById", newVal.id);
}
} else {
this.$store.commit("artists/resetSelectedArtist");
}
} else {
this.$store.commit("artists/resetSelectedArtist");
}
},
},
components: {
ArtistViewer,
},
};
</script>

148
src/views/Boxes.vue Normal file
View File

@@ -0,0 +1,148 @@
<template>
<div class="flex-column" style="max-height: 100%; height: 100%">
<MessageScreen
title="Still no Video content"
subtitle="You still don't have any Video content on your instance"
icon="video"
:commands="messageCommands"
@commandClicked="sync"
:showCommands="$store.getters['user/isAdministrator']"
v-if="boxes.length == 0 && !loading"
/>
<BoxViewer />
<div ref="boxes" id="boxes" @scroll="loadNextPage" @resize="loadNextPage">
<BoxItem
class="ma"
v-for="box in boxes"
:key="box._id"
:item="box"
@click="openBox(box)"
/>
<div
id="boxesLoadingControl"
class="loadingItem"
@click="loadNextPage"
v-if="!eos"
>
Loading next Boxes...
</div>
</div>
</div>
</template>
<script>
import BoxViewer from "../components/dialogs/BoxViewer";
import { mapGetters } from "vuex";
export default {
name: "BoxesView",
data() {
return {
scrollPosition: 0,
messageCommands: [
{
title: "Scan for Video files",
subtitle: "Scann your server for video files…",
icon: "sync",
command: "scan",
},
],
viewMenu: [
{
title: "Synchronize Videos",
icon: "sync-alt",
roles: ["admin"],
event: this.sync,
},
],
};
},
mounted() {
if (this.$route.query.id) {
this.$store.dispatch("boxes/loadBox", this.$route.query.id);
} else {
this.loadNextPage(true);
}
this.$store.dispatch("setViewMenu", this.viewMenu);
},
computed: {
...mapGetters({
boxes: ["boxes/collection"],
loading: ["boxes/loading"],
eos: ["boxes/eos"],
}),
},
methods: {
openBox(box) {
this.$store.dispatch("boxes/selectBox", box);
},
loadNextPage(force) {
this.scrollPosition = this.$refs.boxes.scrollTop;
if ((!this.eos && this.isLoadingControlVisible()) || force === true) {
this.$store.dispatch("boxes/loadBoxes", force);
}
},
isLoadingControlVisible() {
let element = document.getElementById("boxesLoadingControl");
if (!element) {
return false;
}
let bounding = element.getBoundingClientRect();
let vVisible =
bounding.top - 256 <=
(window.innerHeight || document.documentElement.clientHeight) &&
bounding.top > -256;
return vVisible;
},
sync() {
this.$store.dispatch("startScanningVideos");
setTimeout(() => {
this.loadNextPage(true);
}, 3000);
},
},
watch: {
loading(newVal) {
if (!newVal) {
this.$nextTick(() => {
this.loadNextPage();
if (this.$store.getters["boxes/selectedBox"]._id) {
this.$store.dispatch("boxes/preload");
}
});
}
},
"$route.path": function (newVal) {
if (newVal == "/boxes") {
this.$nextTick(() => {
this.$refs.boxes.scrollTop = this.scrollPosition;
this.$store.dispatch("setViewMenu", this.viewMenu);
if (!this.loading) {
this.loadNextPage();
}
});
}
},
"$route.query": function (newVal) {
if (this.$route.path == "/boxes") {
if (newVal.id) {
if (this.$store.getters["boxes/selectedBox"]._id != newVal.id) {
this.$store.dispatch("boxes/selectBoxById", newVal.id);
}
} else {
this.$store.commit("boxes/resetSelectedBox");
}
if (!newVal.play) {
this.$store.dispatch("videos/resetSelectedVideo");
}
} else {
this.$store.commit("boxes/resetSelectedBox");
}
},
},
components: {
BoxViewer,
},
};
</script>

59
src/views/Favourites.vue Normal file
View File

@@ -0,0 +1,59 @@
<template>
<div class="flex-column" style="max-height: 100%; height: 100%">
<div id="favourites">
<h2 class="ma-left ma-top ma4-bottom" v-if="albums.length > 0">Albums</h2>
<div id="albums" class="flex-row">
<AlbumItem
class="ma"
v-for="album in albums"
:key="album._id"
:item="album"
/>
</div>
<h2 class="ma-left ma-top ma4-bottom ma-right" v-if="artists.length > 0">
<hr class="ma-bottom" v-if="albums.length > 0" />
Artists
</h2>
<div id="artists" class="flex-row">
<ArtistItem
class="ma"
v-for="artist in artists"
:key="artist._id"
:item="artist"
/>
</div>
<h2 class="ma-left ma-top ma4-bottom ma-right" v-if="boxes.length > 0">
<hr class="ma-bottom" v-if="albums.length > 0 || artists.length > 0" />
Videos
</h2>
<div id="boxes" class="flex-row">
<BoxItem class="ma" v-for="box in boxes" :key="box._id" :item="box" />
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "FavouritesView",
data() {
return {};
},
mounted() {
this.$store.dispatch("albums/loadFavourites");
this.$store.dispatch("artists/loadFavourites");
this.$store.dispatch("boxes/loadFavourites");
},
methods: {},
computed: {
...mapGetters({
albums: ["albums/favourites"],
artists: ["artists/favourites"],
boxes: ["boxes/favourites"],
}),
},
};
</script>
<style scoped>
</style>

224
src/views/Home.vue Normal file
View File

@@ -0,0 +1,224 @@
<template>
<div id="welcome" ref="welcome" @scroll="loadNextPage">
<div id="welcomeLeft" class="flex-column grow">
<div id="banner" class="center flex-column shadow">
<h1>WebPlay</h1>
<p>
<b>{{ serverInfo.stats.tracks.toLocaleString("de-DE") }}</b> Tracks
and
<b>{{ serverInfo.stats.videos.toLocaleString("de-DE") }}</b> Videos |
Users:
<b>{{ serverInfo.stats.users }}</b>
</p>
</div>
<MessageScreen
title="First Run?"
subtitle="You still don't have any Music or Video content on your instance"
icon="sync"
:commands="messageCommands"
@commandClicked="messageCommand"
:showCommands="$store.getters['user/isAdministrator']"
v-if="serverInfo.stats.tracks == 0 && serverInfo.stats.videos == 0"
/>
<MessageScreen
v-else-if="mostListened.length == 0 && mostViewed.length == 0"
title="Still no history or trends"
subtitle="Still no history or trends on this instance"
icon="info"
/>
<template v-else>
<h2 class="ma-left ma-top pa-top ma4-bottom" v-if="history.length > 0">
Last played
</h2>
<template v-if="history.length > 0">
<div id="history" :class="{ more: historyAll == true }">
<template v-for="item in history">
<AlbumItem
class="ma8"
v-if="item.type == 'album'"
:item="item"
:key="item._id"
/>
<ArtistItem
class="ma8"
v-if="item.type == 'artist'"
:item="item"
:key="item._id"
/>
<BoxItem
class="ma8"
v-if="item.type == 'box'"
:item="item"
:key="item._id"
/>
<RadioItem
class="ma8"
v-if="item.type == 'radio'"
:item="item"
:key="item._id"
/>
</template>
</div>
<span class="pa-top pa-right right" @click="toggleHistory">
<awesome-icon
:icon="historyAll ? 'arrow-up' : 'arrow-down'"
class="pa8-right"
/>{{ historyToggleText }}</span
>
</template>
<div id="mostUsed" class="flex-row ma">
<div id="mostListened" class="grow" v-if="mostListened.length > 0">
<h2 class="ma-top pa-top ma4-bottom">Most listened</h2>
<div class="flex-column">
<TrackItem
v-for="(item, i) in mostListened"
:track="item"
:key="i"
/>
</div>
</div>
<div id="mostViewed" class="grow" v-if="mostViewed.length > 0">
<h2 class="ma-top pa-top ma4-bottom">Most viewed</h2>
<div id="mostViewedVideos" class="flex-row">
<VideoItem
v-for="(item, i) in mostViewed"
:video="item"
:key="i"
/>
</div>
</div>
</div>
</template>
</div>
<div
v-if="newestAlbums.length > 0 || newestBoxes.length > 0"
id="newest"
class="pa-left pa-right"
>
<template v-if="newestAlbums.length > 0">
<h3>Newest Music</h3>
<div id="newestMusic" class="flex-column pa-bottom">
<AlbumItem
class="ma8"
v-for="item in newestAlbums"
type="line"
:item="item"
:key="item._id"
/>
</div>
</template>
<template v-if="newestBoxes.length > 0">
<h3>Newest Videos</h3>
<div id="newestVideos" class="flex-row">
<BoxItem
class="ma8 small"
v-for="item in newestBoxes"
:item="item"
:key="item._id"
/>
</div>
</template>
</div>
</div>
</template>
<script>
import RadioItem from "../components/Radio";
import TrackItem from "../components/Track";
import VideoItem from "../components/Video";
import { mapGetters } from "vuex";
export default {
name: "HomeView",
data() {
return {
scrollPosition: 0,
historyAll: false,
messageCommands: [
{
title: "Scan for Music files",
subtitle: "Scann your server for music files…",
icon: "music",
command: "scanMusic",
},
{
title: "Scan for Video files",
subtitle: "Scann your server for video files…",
icon: "video",
command: "scanVideos",
},
],
};
},
mounted() {
this.loadItems();
},
methods: {
loadItems() {
this.$refs.welcome.scrollTop = this.scrollPosition;
this.$store.dispatch("albums/loadNewest");
this.$store.dispatch("boxes/loadNewest");
this.$store.dispatch("tracks/loadMostListened");
this.$store.dispatch("videos/loadMostViewed");
},
loadNextPage() {
this.scrollPosition = this.$refs.welcome.scrollTop;
},
messageCommand(req) {
switch (req.command) {
case "scanMusic":
this.$store.dispatch("startScanningMusic");
setTimeout(() => {
this.$store.dispatch("albums/loadAlbums", true);
this.$router.push("/albums");
}, 3000);
break;
case "scanVideos":
this.$store.dispatch("startScanningVideos");
setTimeout(() => {
this.$store.dispatch("boxes/loadBoxes", true);
this.$router.push("/boxes");
}, 3000);
break;
}
},
toggleHistory() {
this.historyAll = !this.historyAll;
},
},
computed: {
...mapGetters({
history: "user/history",
newestAlbums: "albums/newest",
newestBoxes: "boxes/newest",
serverInfo: "serverInfo",
mostListened: "tracks/mostListened",
mostViewed: "videos/mostViewed",
}),
historyToggleText() {
return this.historyAll ? "less..." : "more...";
},
},
watch: {
"$route.path": function (newVal) {
if (newVal == "/") {
this.$store.dispatch("resetViewMenu", this.viewMenu);
this.$nextTick(() => {
this.loadItems();
});
}
},
},
components: {
RadioItem,
TrackItem,
VideoItem,
},
};
</script>
<style scoped>
#history.more {
max-height: initial;
}
</style>

222
src/views/Login.vue Normal file
View File

@@ -0,0 +1,222 @@
<template>
<div id="login">
<div id="loginViewer">
<div id="loginLogin" class="flex-column">
<img id="loginLogo" src="static/icon_64_flat.svg" />
<h1>Web Play</h1>
<form id="loginForm" @submit.prevent="login" method="POST">
<p>
<input
ref="username"
name="username"
type="text"
placeholder="username"
id="username"
/>
</p>
<p>
<input
ref="password"
name="password"
type="password"
placeholder="password"
id="password"
/>
</p>
<ExpanderControl
title="Server configuration"
v-if="$store.getters.isElectron"
>
<p>
<input
ref="backend"
name="backend"
type="url"
placeholder="backend"
id="backend"
v-model.lazy="server"
@change="backendChanged"
/>
</p>
</ExpanderControl>
<button class="flat gray-border" action="submit">Login</button>
</form>
<p>{{ message }}</p>
</div>
<div
id="loginContinue"
class="flex-column"
v-if="allow_guests || allow_registration"
>
<div id="loginGuest" class="flex-column" v-if="allow_guests">
<button @click="continueAsGuest">
<awesome-icon icon="music" class="ma" /><span class="ma4"
>Continue as Guest</span
>
</button>
</div>
<p class="center ma pa-top" v-if="allow_guests && allow_registration">
or
</p>
<div
id="loginRegestration"
class="flex-column ma-right ma-left"
v-if="allow_registration"
>
<ExpanderControl title="Register now" class="ma-right ma-left">
<form autocomplete="off" method="POST">
<p>
<input
type="email"
placeholder="Email adress"
v-model="newUserAcc.email"
autocomplete="one-time-code"
/>
</p>
<p>
<input
type="text"
v-model="newUserAcc.name"
placeholder="Account"
autocomplete="one-time-code"
/>
</p>
<p>
<input
type="password"
placeholder="Password"
v-model="newUserAcc.pass"
autocomplete="one-time-code"
pattern=".{5,}"
/>
</p>
<button class="flat gray-border" action="submit">Register</button>
</form>
</ExpanderControl>
</div>
</div>
</div>
</div>
</template>
<script>
import ExpanderControl from "../components/base-components/Expander";
export default {
name: "LoginView",
data() {
return {
message: "",
finished_checks: false,
newUserAcc: { name: "", email: "", pass: "" },
};
},
mounted() {
this.$nextTick(() => {
this.$refs.username.focus();
});
},
methods: {
checkTocken() {
let token = localStorage.getItem("token");
if (token) {
this.$store.dispatch("user/setToken", token);
this.$store
.dispatch("user/load")
.then(() => {
this.goto();
})
.catch((err) => {
console.log(err);
this.checkIfNewBackend();
});
} else {
this.checkIfNewBackend();
}
},
login() {
this.$store
.dispatch("user/login", {
username: this.$refs.username.value,
password: this.$refs.password.value,
})
.then(() => {
this.goto();
})
.catch((err) => {
if (err.status == 401) {
this.message = "Login failed…";
} else {
this.message = "Server error…";
}
this.$refs.password.focus();
this.$refs.password.select();
});
},
checkIfNewBackend() {
this.$store.dispatch("checkIfInstanceIsNew").then((res) => {
if (res) {
this.$router.replace("setup");
} else if (this.$route.query.redirect) {
this.continueAsGuest();
}
this.finished_checks = true;
});
},
continueAsGuest() {
if (this.allow_guests) {
this.$store.dispatch("user/useGuestAccount").then(() => {
this.goto();
});
}
},
goto() {
if (this.$route.query.redirect) {
let redirect = decodeURIComponent(this.$route.query.redirect);
let route = redirect.split("?");
if (route[1]) {
let urlParams = new URLSearchParams(route[1]);
let params = Object.fromEntries(urlParams);
this.$router.replace({ path: route[0], query: params });
} else {
this.$router.replace(route[0]);
}
} else {
this.$router.replace("/");
}
},
backendChanged() {
this.$store.dispatch("setNewBackend", this.$refs.backend.value);
},
},
computed: {
server() {
return this.$store.getters.server;
},
allow_guests() {
return this.$store.state.serverConfig.allows.guests;
},
allow_registration() {
return this.$store.state.serverConfig.allows.register;
},
},
watch: {
server() {
this.checkTocken();
},
allow_guests(newVal) {
if (newVal && this.finished_checks && this.$route.query.redirect) {
this.continueAsGuest();
}
},
"$route.path": function (newVal) {
if (newVal == "/artists") {
this.$nextTick(() => {
this.$refs.username.focus();
});
}
},
},
components: { ExpanderControl },
};
</script>

77
src/views/Radios.vue Normal file
View File

@@ -0,0 +1,77 @@
<template>
<div ref="radios" id="radios" @scroll="loadNextPage">
<RadioItem v-for="(radio, i) in radios" :key="i" :item="radio" />
<MessageScreen
v-if="radios.length == 0"
title="No Radios"
subtitle="Import some Online Radios into your Library"
icon="podcast"
:commands="messageCommands"
@commandClicked="addRadio"
:showCommands="$store.getters['user/isAdministrator']"
/>
<RadiosEditor ref="radiosEditor" />
</div>
</template>
<script>
import RadioItem from "../components/Radio";
import RadiosEditor from "../components/dialogs/Radios";
import { mapGetters } from "vuex";
export default {
name: "RadiosView",
data() {
return {
scrollPosition: 0,
messageCommands: [
{
title: "Add Online Radio",
subtitle: "Add an url into your Library…",
icon: "plus",
},
],
viewMenu: [
{
title: "Manage Radio Stations",
icon: "podcast",
roles: ["moderator", "admin"],
event: this.addRadio,
},
],
};
},
mounted() {
this.$store.dispatch("radios/loadRadios");
this.$store.dispatch("setViewMenu", this.viewMenu);
},
computed: {
...mapGetters({ radios: "radios/collection" }),
},
methods: {
loadNextPage() {
this.scrollPosition = this.$refs.radios.scrollTop;
this.$store.dispatch("radios/loadRadios");
},
addRadio() {
this.$refs.radiosEditor.open();
},
},
watch: {
"$route.path": function (newVal) {
if (newVal == "/radios") {
this.$nextTick(() => {
this.$refs.radios.scrollTop = this.scrollPosition;
this.$store.dispatch("setViewMenu", this.viewMenu);
if (!this.loading) {
this.loadNextPage();
}
});
}
},
},
components: {
RadioItem,
RadiosEditor,
},
};
</script>

99
src/views/Search.vue Normal file
View File

@@ -0,0 +1,99 @@
<template>
<div
ref="search"
id="search"
@scroll="checkCoverVisibility"
@resize="checkCoverVisibility"
>
<template v-for="item in collection">
<AlbumItem
class="ma"
v-if="item.type == 'album'"
:item="item"
:key="item._id"
/>
<ArtistItem
class="ma"
v-if="item.type == 'artist'"
:item="item"
:key="item._id"
/>
<BoxItem
class="ma"
v-if="item.type == 'box'"
:item="item"
:key="item._id"
/>
<RadioItem
class="ma"
v-if="item.type == 'radio'"
:item="item"
:key="item._id"
/>
<TrackItem v-if="item.type == 'track'" :track="item" :key="item._id" />
<VideoItem v-if="item.type == 'video'" :video="item" :key="item._id" />
</template>
<MessageScreen
v-if="collection.length == 0"
title="No Results"
subtitle="Try an other search term…"
icon="search"
/>
</div>
</template>
<script>
import TrackItem from "../components/Track";
import VideoItem from "../components/Video";
import RadioItem from "../components/Radio";
import { mapGetters } from "vuex";
export default {
name: "SearchView",
data() {
return {
scrollPosition: 0,
};
},
mounted() {
if (this.$route.query.q) {
this.$store.commit("search/setTerm", this.$route.query.q);
}
this.$store.dispatch("setViewMenu", this.viewMenu);
},
methods: {
checkCoverVisibility() {
this.scrollPosition = this.$refs.search.scrollTop;
},
searchRequest(q) {
this.$store.dispatch("search/search", q);
},
},
computed: {
...mapGetters({
term: "search/term",
collection: "search/collection",
search: "search/collection",
}),
},
watch: {
"$route.path": function (newVal) {
if (newVal == "/search") {
this.$nextTick(() => {
this.$refs.search.scrollTop = this.scrollPosition;
this.$store.dispatch("setViewMenu", this.viewMenu);
});
}
},
term(newVal) {
if (newVal && newVal != "") {
this.searchRequest(newVal);
}
},
},
components: {
VideoItem,
RadioItem,
TrackItem,
},
};
</script>

88
src/views/Setup.vue Normal file
View File

@@ -0,0 +1,88 @@
<template>
<div id="setup">
<img id="loginLogo" src="static/icon_64_flat.svg" />
<h1>WebPlay Setup</h1>
<form id="setupForm" @submit.prevent="setup" method="POST">
<p>
<input
ref="username"
name="username"
type="text"
placeholder="username"
id="username"
/>
</p>
<p>
<input
ref="password"
name="password"
type="password"
placeholder="password"
id="password"
/>
</p>
<p v-if="$store.getters.isElectron">
<input
ref="backend"
name="backend"
type="url"
placeholder="backend"
id="backend"
v-model.lazy="server"
@change="backendChanged"
/>
</p>
<button class="flat" action="submit">Create admin account</button>
</form>
<p>{{ message }}</p>
</div>
</template>
<script>
export default {
name: "SetupView",
data() {
return {
message: "",
};
},
mounted() {
if (this.server != "none") {
this.checkIfNewBackend();
}
},
methods: {
setup() {
let user = {
username: this.$refs.username.value,
password: this.$refs.password.value,
};
this.$store.dispatch("system/createInstanceAccess", user);
},
checkIfNewBackend() {
this.$store.dispatch("checkIfInstanceIsNew").then((res) => {
if (!res) {
this.$router.replace("login");
}
});
},
backendChanged() {
this.$http
.post("/settings", { backend: this.$refs.backend.value })
.then(() => {
this.$store.state.server = this.$refs.backend.value;
});
},
},
computed: {
server() {
return this.$store.getters.server;
},
},
watch: {
server() {
this.checkIfNewBackend();
},
},
};
</script>

64
src/views/Users.vue Normal file
View File

@@ -0,0 +1,64 @@
<template>
<div class="flex-row pa">
<div class="flex-column">
<img id="profile" src="/static/icons/dummy/artist.svg" alt="" />
<h2>{{ me.name }}</h2>
<input type="text" class="h1 borderless" v-model="me.fullname" />
<h3>Prefered Language</h3>
<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>
<h3>Video quality</h3>
<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>
<h3>Audio quality [kBin/s]</h3>
<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>
</div>
<div class="flex-column">
<h3>My Music</h3>
<div class="albums"></div>
<h3>My Videos</h3>
<div class="boxes"></div>
</div>
</div>
</template>
<script>
export default {
name: "UsersView",
data() {
return {};
},
computed: {
me() {
return this.$store.state.user;
},
},
};
</script>
<style scoped>
img#profile {
width: 512px;
max-width: 100%;
}
</style>