10 Commitit d92be8c7a0 ... d348bf3546

Tekijä SHA1 Viesti Päivämäärä
  Owen Diffey d348bf3546 feat: Add station search tab 1 viikko sitten
  Owen Diffey e535afa82e feat: Add youtube search item component 1 viikko sitten
  Owen Diffey 92263efe40 feat: Add button success theme 1 viikko sitten
  Owen Diffey 08fa9427b9 Merge branch 'staging' into feature/station-ui 1 viikko sitten
  Owen Diffey e54d2ba15b chore: Disable MD059 markdown lint rule 1 viikko sitten
  Owen Diffey 1fffde64b1 fix(musare.sh): Pull images used for linting 1 viikko sitten
  Owen Diffey 70a4fdc8a7 refactor: Provide media source directly to add to playlist dropdown 1 viikko sitten
  Owen Diffey 8654f8a392 feat: Add featured action slot to media item 1 viikko sitten
  Owen Diffey fc7bc9fd03 feat: Add media item verified icon 1 viikko sitten
  Owen Diffey a8ff8e964d feat: Add show requested prop to media item 1 viikko sitten

+ 2 - 1
.markdownlint.json

@@ -3,5 +3,6 @@
         "tables": false
     },
     "MD024": false,
-    "MD041": false
+    "MD041": false,
+    "MD059": false
 }

+ 1 - 1
backend/logic/songs.js

@@ -1019,7 +1019,7 @@ class _SongsModule extends CoreClass {
 
 					(filterArray, next) => {
 						const page = payload.page ? payload.page : 1;
-						const pageSize = 15;
+						const pageSize = 10;
 						const skipAmount = pageSize * (page - 1);
 						const query = { $or: filterArray };
 

+ 4 - 7
frontend/src/pages/NewStation/Components/AddToPlaylistDropdown.vue

@@ -7,7 +7,6 @@ import {
 import Toast from "toasters";
 import { PlaylistModel } from "@musare_types/models/Playlist";
 import { storeToRefs } from "pinia";
-import { Song } from "@/types/song";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserPlaylistsStore } from "@/pages/NewStation/stores/userPlaylists";
 import { useModalsStore } from "@/stores/modals";
@@ -19,7 +18,7 @@ const DropdownList = defineAsyncComponent(
 );
 
 const props = defineProps<{
-	media: Song;
+	mediaSource: string;
 }>();
 
 const { openModal } = useModalsStore();
@@ -30,15 +29,13 @@ const { socket } = useWebsocketsStore();
 const dropdown = ref();
 
 const existsInPlaylist = (playlist: PlaylistModel & { weight: number }) =>
-	!!playlist.songs.find(
-		media => media.mediaSource === props.media.mediaSource
-	);
+	!!playlist.songs.find(media => media.mediaSource === props.mediaSource);
 
 const addToPlaylist = (playlist: PlaylistModel & { weight: number }) => {
 	socket.dispatch(
 		"playlists.addSongToPlaylist",
 		false,
-		props.media.mediaSource,
+		props.mediaSource,
 		playlist._id,
 		(res: AddSongToPlaylistResponse) => new Toast(res.message)
 	);
@@ -47,7 +44,7 @@ const addToPlaylist = (playlist: PlaylistModel & { weight: number }) => {
 const removeFromPlaylist = (playlist: PlaylistModel & { weight: number }) => {
 	socket.dispatch(
 		"playlists.removeSongFromPlaylist",
-		props.media.mediaSource,
+		props.mediaSource,
 		playlist._id,
 		(res: RemoveSongFromPlaylistResponse) => new Toast(res.message)
 	);

+ 9 - 2
frontend/src/pages/NewStation/Components/Button.vue

@@ -8,6 +8,7 @@ withDefaults(
 		inverse?: boolean;
 		danger?: boolean;
 		grey?: boolean;
+		success?: boolean;
 	}>(),
 	{
 		type: "button",
@@ -16,7 +17,8 @@ withDefaults(
 		square: false,
 		inverse: false,
 		danger: false,
-		grey: false
+		grey: false,
+		success: false
 	}
 );
 </script>
@@ -29,7 +31,8 @@ withDefaults(
 			'btn--square': square,
 			'btn--inverse': inverse,
 			'btn--danger': danger,
-			'btn--grey': grey
+			'btn--grey': grey,
+			'btn--success': success
 		}"
 		:disabled="disabled"
 	>
@@ -90,6 +93,10 @@ withDefaults(
 		border-color: var(--light-grey-1);
 	}
 
+	&--success {
+		--primary-color: var(--green);
+	}
+
 	&__icon {
 		font-size: 18px;
 	}

+ 56 - 28
frontend/src/pages/NewStation/Components/MediaItem.vue

@@ -24,9 +24,15 @@ const UserLink = defineAsyncComponent(
 
 // TODO: Experimental: soundcloud
 
-const props = defineProps<{
-	media: any;
-}>();
+const props = withDefaults(
+	defineProps<{
+		media: any;
+		showRequested?: boolean;
+	}>(),
+	{
+		showRequested: false
+	}
+);
 
 const { openModal } = useModalsStore();
 
@@ -83,38 +89,52 @@ defineExpose({
 		<div class="media-item__content">
 			<p class="media-item__title" :title="media.title">
 				{{ media.title }}
+				<i
+					v-if="media.verified"
+					class="material-icons media-item__verified"
+					title="Verified media"
+				>
+					check_circle
+				</i>
 			</p>
 			<p class="media-item__artists" :title="media.artists?.join(', ')">
 				{{ media.artists?.join(", ") }}
 			</p>
-			<p
-				v-if="media.requestedBy || media.requestedType"
-				class="media-item__requested"
-			>
+			<p class="media-item__details">
 				<strong>
 					{{ dayjs.duration(media.duration, "s").formatDuration() }}
 				</strong>
-				<span class="media-item__divider">&middot;</span>
-				<UserLink
-					v-if="media.requestedBy"
-					:key="media.mediaSource"
-					:user-id="media.requestedBy"
-				/>
-				<span v-else>Station</span>
-				<span>
-					<template v-if="media.requestedType === 'autofill'">
-						requested automatically
-					</template>
-					<template v-else-if="media.requestedType === 'autorequest'">
-						autorequested
-					</template>
-					<template v-else>requested</template>
-				</span>
-				<span :title="dayjs(media.requestedAt).format()">{{
-					dayjs(media.requestedAt).fromNow()
-				}}</span>
+				<template
+					v-if="
+						showRequested &&
+						(media.requestedBy || media.requestedType)
+					"
+				>
+					<span class="media-item__divider">&middot;</span>
+					<UserLink
+						v-if="media.requestedBy"
+						:key="media.mediaSource"
+						:user-id="media.requestedBy"
+					/>
+					<span v-else>Station</span>
+					<span>
+						<template v-if="media.requestedType === 'autofill'">
+							requested automatically
+						</template>
+						<template
+							v-else-if="media.requestedType === 'autorequest'"
+						>
+							autorequested
+						</template>
+						<template v-else>requested</template>
+					</span>
+					<span :title="dayjs(media.requestedAt).format()">{{
+						dayjs(media.requestedAt).fromNow()
+					}}</span>
+				</template>
 			</p>
 		</div>
+		<slot name="featuredAction" />
 		<DropdownList ref="actions">
 			<Button icon="more_horiz" square inverse title="Actions" />
 
@@ -123,7 +143,9 @@ defineExpose({
 
 				<template v-if="loggedIn">
 					<DropdownListItem>
-						<AddToPlaylistDropdown :media="media">
+						<AddToPlaylistDropdown
+							:media-source="media.mediaSource"
+						>
 							<button class="dropdown-list-item__action">
 								<span
 									class="material-icons dropdown-list-item__icon"
@@ -167,6 +189,7 @@ defineExpose({
 <style lang="less" scoped>
 .media-item {
 	display: flex;
+	align-items: center;
 	flex-shrink: 0;
 	height: 48px;
 	background-color: var(--white);
@@ -201,6 +224,11 @@ defineExpose({
 		white-space: nowrap;
 	}
 
+	&__verified {
+		color: var(--primary-color);
+		font-size: 12px !important;
+	}
+
 	&__artists {
 		display: inline-flex;
 		align-items: center;
@@ -213,7 +241,7 @@ defineExpose({
 		white-space: nowrap;
 	}
 
-	&__requested {
+	&__details {
 		display: inline-flex;
 		align-items: center;
 		gap: 2px;

+ 175 - 0
frontend/src/pages/NewStation/Components/YoutubeSearchItem.vue

@@ -0,0 +1,175 @@
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from "vue";
+import { useModalsStore } from "@/stores/modals";
+
+const AddToPlaylistDropdown = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/AddToPlaylistDropdown.vue")
+);
+const Button = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Button.vue")
+);
+const DropdownList = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/DropdownList.vue")
+);
+const DropdownListItem = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/DropdownListItem.vue")
+);
+const SongThumbnail = defineAsyncComponent(
+	() => import("@/components/SongThumbnail.vue")
+);
+
+const props = defineProps<{
+	item: {
+		id: string;
+		url: string;
+		title: string;
+		thumbnail: string;
+		channelId: string;
+		channelTitle: string;
+		isAddedToQueue: boolean;
+	};
+}>();
+
+const { openModal } = useModalsStore();
+
+const actions = ref();
+
+const expandActions = () => {
+	actions.value.expand();
+};
+
+const collapseActions = () => {
+	actions.value.collapse();
+};
+
+const view = () => {
+	collapseActions();
+
+	openModal({
+		modal: "viewMedia",
+		props: { mediaSource: `youtube:${props.item.id}` }
+	});
+};
+
+defineExpose({
+	expandActions,
+	collapseActions
+});
+</script>
+
+<template>
+	<div class="youtube-search-item">
+		<SongThumbnail
+			:song="{
+				thumbnail: item.thumbnail,
+				youtubeId: item.id
+			}"
+		/>
+		<div class="youtube-search-item__content">
+			<p class="youtube-search-item__title" :title="item.title">
+				{{ item.title }}
+			</p>
+			<a
+				v-if="item.channelTitle && item.channelId"
+				class="youtube-search-item__channel"
+				:title="item.channelTitle"
+				:href="'https://youtube.com/channel/' + item.channelId"
+				target="_blank"
+			>
+				{{ item.channelTitle }}
+			</a>
+		</div>
+		<slot name="featuredAction" />
+		<DropdownList ref="actions">
+			<Button icon="more_horiz" square inverse title="Actions" />
+
+			<template #options>
+				<slot name="actions" />
+
+				<DropdownListItem>
+					<AddToPlaylistDropdown :media-source="`youtube:${item.id}`">
+						<button class="dropdown-list-item__action">
+							<span
+								class="material-icons dropdown-list-item__icon"
+								aria-hidden="true"
+							>
+								playlist_add
+							</span>
+							Add to playlist
+						</button>
+					</AddToPlaylistDropdown>
+				</DropdownListItem>
+				<DropdownListItem
+					icon="play_arrow"
+					label="View media"
+					@click="view"
+				/>
+			</template>
+		</DropdownList>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.youtube-search-item {
+	display: flex;
+	align-items: center;
+	flex-shrink: 0;
+	height: 48px;
+	background-color: var(--white);
+	border-radius: 5px;
+	border: solid 1px var(--light-grey-1);
+	gap: 5px;
+	overflow: hidden;
+
+	:deep(.thumbnail) {
+		height: 48px;
+		min-width: 48px;
+		flex-shrink: 0;
+		margin: 0;
+	}
+
+	&__content {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		min-width: 0;
+		justify-content: center;
+	}
+
+	&__title {
+		font-size: 11.75px !important;
+		line-height: 14px;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-box-orient: vertical;
+		-webkit-line-clamp: 2;
+		word-wrap: break-word;
+	}
+
+	&__channel {
+		display: inline-flex;
+		align-items: center;
+		align-self: start;
+		font-size: 10px !important;
+		font-weight: 500 !important;
+		line-height: 12px;
+		color: var(--dark-grey-1);
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	& > :deep(.dropdown-list__reference) {
+		display: flex;
+		flex-direction: column;
+		justify-content: center;
+		padding-right: 5px;
+	}
+
+	:deep(.dropdown-list-item > .dropdown-list__reference) {
+		display: flex;
+		flex-grow: 1;
+	}
+}
+</style>

+ 1 - 0
frontend/src/pages/NewStation/Queue.vue

@@ -140,6 +140,7 @@ onMounted(() => {
 				<MediaItem
 					:media="media"
 					:ref="el => (mediaItems[`media-item-${index}`] = el)"
+					show-requested
 				>
 					<template
 						v-if="

+ 333 - 0
frontend/src/pages/NewStation/Search.vue

@@ -0,0 +1,333 @@
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, ref } from "vue";
+import { storeToRefs } from "pinia";
+import Toast from "toasters";
+import { useSearchYoutube } from "@/composables/useSearchYoutube";
+import { useSearchMusare } from "@/composables/useSearchMusare";
+import { useYoutubeDirect } from "@/composables/useYoutubeDirect";
+import { useSoundcloudDirect } from "@/composables/useSoundcloudDirect";
+import { useConfigStore } from "@/stores/config";
+import { Station } from "@/types/station";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+const Button = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Button.vue")
+);
+const Input = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Input.vue")
+);
+const InputGroup = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/InputGroup.vue")
+);
+const MediaItem = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/MediaItem.vue")
+);
+const Select = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Select.vue")
+);
+const YoutubeSearchItem = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/YoutubeSearchItem.vue")
+);
+
+const props = defineProps<{
+	station: Station;
+}>();
+
+const configStore = useConfigStore();
+const { experimental, sitename } = storeToRefs(configStore);
+
+const { socket } = useWebsocketsStore();
+const { youtubeSearch, searchForSongs, loadMoreSongs } = useSearchYoutube();
+const { musareSearch, searchForMusareSongs } = useSearchMusare();
+const { youtubeDirect, addToQueue: addYoutubeToQueue } = useYoutubeDirect();
+const { soundcloudDirect, addToQueue: addSoundcloudToQueue } =
+	useSoundcloudDirect();
+
+const addExternalQuery = ref("");
+const searchSource = ref("local");
+const originalSearchSource = ref("local");
+const searchQuery = ref("");
+const originalSearchQuery = ref("");
+
+const searchResults = computed(() => {
+	if (originalSearchQuery.value === "") return [];
+
+	if (originalSearchSource.value === "youtube")
+		return youtubeSearch.value.songs.results;
+
+	return musareSearch.value.results;
+});
+
+const hasMoreResults = computed(() => {
+	if (originalSearchQuery.value === "") return false;
+
+	if (originalSearchSource.value === "youtube") return true;
+
+	return musareSearch.value.count - musareSearch.value.results.length;
+});
+
+const addToQueue = (media: any) => {
+	socket.dispatch(
+		"stations.addToQueue",
+		props.station._id,
+		media.mediaSource,
+		"manual",
+		res => {
+			if (res.status === "success") {
+				media.isAddedToQueue = true;
+
+				new Toast(res.message);
+			} else new Toast(`Error: ${res.message}`);
+		}
+	);
+};
+
+const addYoutubeItemToQueue = (result: any) => {
+	socket.dispatch(
+		"stations.addToQueue",
+		props.station._id,
+		`youtube:${result.id}`,
+		"manual",
+		res => {
+			if (res.status === "success") {
+				result.isAddedToQueue = true;
+
+				new Toast(res.message);
+			} else new Toast(`Error: ${res.message}`);
+		}
+	);
+};
+
+const addExternal = () => {
+	if (addExternalQuery.value === "") return;
+
+	if (
+		experimental.value.soundcloud &&
+		(addExternalQuery.value.startsWith("soundcloud:") ||
+			addExternalQuery.value.indexOf("soundcloud.com") !== -1)
+	) {
+		soundcloudDirect.value = addExternalQuery.value;
+		addSoundcloudToQueue(props.station._id);
+
+		addExternalQuery.value = "";
+
+		return;
+	}
+
+	youtubeDirect.value = addExternalQuery.value;
+	addYoutubeToQueue(props.station._id);
+
+	addExternalQuery.value = "";
+};
+
+const search = () => {
+	originalSearchSource.value = experimental.value.disable_youtube_search
+		? "local"
+		: searchSource.value;
+	originalSearchQuery.value = searchQuery.value;
+
+	if (originalSearchQuery.value === "") return;
+
+	if (originalSearchSource.value === "youtube") {
+		youtubeSearch.value.songs.query = originalSearchQuery.value;
+		searchForSongs();
+
+		return;
+	}
+
+	musareSearch.value.query = originalSearchQuery.value;
+	searchForMusareSongs(1);
+};
+
+const loadMoreResults = () => {
+	if (originalSearchSource.value === "youtube") {
+		loadMoreSongs();
+
+		return;
+	}
+
+	searchForMusareSongs(musareSearch.value.page + 1);
+};
+
+const resetSearch = () => {
+	searchQuery.value = "";
+	originalSearchQuery.value = "";
+};
+</script>
+
+<template>
+	<section class="search-section">
+		<h2 class="search-section__title">Add external media</h2>
+		<p v-if="experimental.soundcloud" class="search-section__description">
+			Add media to station queue using a YouTube video ID,
+			<br />
+			or a direct link from YouTube or SoundCloud.
+		</p>
+		<p v-else class="search-section__description">
+			Add media to station queue using a YouTube video ID or link.
+		</p>
+		<InputGroup
+			class="search-section__form"
+			is="form"
+			@submit.prevent="addExternal"
+		>
+			<Input
+				class="input_group__expanding"
+				v-model="addExternalQuery"
+				required
+			>
+				Direct link or YouTube video ID
+			</Input>
+			<Button type="submit" icon="add" square title="Add" />
+		</InputGroup>
+	</section>
+	<hr class="search-section-divider" />
+	<section class="search-section">
+		<h2 class="search-section__title">Search for media</h2>
+		<p class="search-section__description">
+			Search for media on {{ sitename }}
+			<template v-if="!experimental.disable_youtube_search">
+				or YouTube
+			</template>
+			to add to the station queue.
+		</p>
+		<InputGroup
+			class="search-section__form"
+			is="form"
+			@submit.prevent="search"
+			@reset.prevent="resetSearch"
+		>
+			<Select
+				v-if="!experimental.disable_youtube_search"
+				v-model="searchSource"
+				:options="{
+					local: sitename,
+					youtube: 'YouTube'
+				}"
+				required
+			>
+				Source
+			</Select>
+			<Input
+				class="input_group__expanding"
+				v-model="searchQuery"
+				required
+			>
+				Query
+			</Input>
+			<Button
+				type="reset"
+				icon="restart_alt"
+				square
+				grey
+				title="Reset search"
+			/>
+			<Button type="submit" icon="search" square title="Search" />
+		</InputGroup>
+		<ul class="search-results">
+			<li
+				v-for="(result, index) in searchResults"
+				:key="index"
+				class="search-results__result"
+			>
+				<YoutubeSearchItem
+					v-if="originalSearchSource === 'youtube'"
+					:item="result"
+				>
+					<template #featuredAction>
+						<Button
+							v-if="result.isAddedToQueue"
+							icon="done"
+							success
+							square
+							title="Added to queue"
+						/>
+						<Button
+							v-else
+							icon="queue"
+							square
+							title="Add to queue"
+							@click.prevent="addYoutubeItemToQueue(result)"
+						/>
+					</template>
+				</YoutubeSearchItem>
+				<MediaItem v-else :media="result">
+					<template #featuredAction>
+						<Button
+							v-if="result.isAddedToQueue"
+							icon="done"
+							success
+							square
+							title="Added to queue"
+						/>
+						<Button
+							v-else
+							icon="queue"
+							square
+							title="Add to queue"
+							@click.prevent="addToQueue(result)"
+						/>
+					</template>
+				</MediaItem>
+			</li>
+		</ul>
+		<Button
+			v-if="hasMoreResults"
+			class="search-load-more"
+			icon="search"
+			inverse
+			@click.prevent="loadMoreResults"
+		>
+			Load more results
+		</Button>
+	</section>
+</template>
+
+<style lang="less" scoped>
+.search-section {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	padding: 10px;
+
+	&__title {
+		font-size: 20px !important;
+		font-weight: 600 !important;
+		margin: 0;
+	}
+
+	&__description {
+		font-size: 14px !important;
+	}
+
+	&__form {
+		max-width: 400px;
+	}
+}
+
+.search-section-divider {
+	background-color: var(--light-grey-2);
+	border-radius: 10px;
+	height: 5px;
+	flex-shrink: 0;
+}
+
+.search-results {
+	display: grid;
+	grid-template-columns: repeat(2, 1fr);
+	gap: 10px;
+
+	&__result {
+		max-width: 100%;
+		overflow: hidden;
+
+		:deep(.media-item) {
+			flex-grow: 1;
+		}
+	}
+}
+
+.search-load-more {
+	align-self: center;
+}
+</style>

+ 15 - 11
frontend/src/pages/NewStation/index.vue

@@ -39,6 +39,12 @@ const Queue = defineAsyncComponent(
 const Button = defineAsyncComponent(
 	() => import("@/pages/NewStation/Components/Button.vue")
 );
+const Tabs = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Tabs.vue")
+);
+const Search = defineAsyncComponent(
+	() => import("@/pages/NewStation/Search.vue")
+);
 
 const props = defineProps<{
 	id: string;
@@ -619,6 +625,7 @@ onBeforeUnmount(() => {
 					<MediaItem
 						v-if="station.currentSong"
 						:media="station.currentSong"
+						show-requested
 					/>
 					<h3
 						style="
@@ -632,17 +639,14 @@ onBeforeUnmount(() => {
 					</h3>
 					<Queue :station="station" />
 				</section>
-				<section
-					style="
-						display: flex;
-						flex-direction: column;
-						flex-grow: 1;
-						padding: 20px;
-						background-color: var(--white);
-						border-radius: 5px;
-						border: solid 1px var(--light-grey-1);
-					"
-				></section>
+				<Tabs
+					:tabs="['Search', 'Explore', 'Settings']"
+					style="flex-grow: 1"
+				>
+					<template #Search>
+						<Search :station="station" />
+					</template>
+				</Tabs>
 			</section>
 			<RightSidebar />
 		</div>

+ 2 - 2
musare.sh

@@ -303,12 +303,12 @@ handleLinting()
     if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *docs* ]]; then
         echo -e "${CYAN}Running docs lint...${NC}"
         # shellcheck disable=SC2086
-        ${docker} run --rm -v "${scriptLocation}":/workdir ghcr.io/igorshubovych/markdownlint-cli:latest ".wiki" "*.md" ${fix}
+        ${docker} run --rm -v "${scriptLocation}":/workdir --pull always ghcr.io/igorshubovych/markdownlint-cli:latest ".wiki" "*.md" ${fix}
         docsExitValue=$?
     fi
     if [[ ${servicesString:2:4} == "all" || "${servicesString:2}" == *shell* ]]; then
         echo -e "${CYAN}Running shell lint...${NC}"
-        ${docker} run --rm -v "${scriptLocation}":/mnt koalaman/shellcheck:stable ./*.sh ./**/*.sh
+        ${docker} run --rm -v "${scriptLocation}":/mnt --pull always koalaman/shellcheck:stable ./*.sh ./**/*.sh
         shellExitValue=$?
     fi
     set -e