Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
793 changes: 793 additions & 0 deletions src/components/smart_playlist/SmartPlaylistRulesForm.vue

Large diffs are not rendered by default.

209 changes: 209 additions & 0 deletions src/layouts/default/CreateSmartPlaylistDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<!--
Global dialog to create a new smart playlist from a set of rules.
Supports two modes: Dynamic (auto-refreshes, saves rules) and Fixed (one-time generation).
Visibility is controlled via the centralized eventbus.
-->
<template>
<Dialog v-model:open="showDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle class="mb-2">
{{ $t("smart_playlist.create") }}
</DialogTitle>
<DialogDescription>
{{ $t("smart_playlist.desc") }}
</DialogDescription>
</DialogHeader>

<div class="flex flex-col gap-4 py-2">
<!-- Playlist name -->
<div class="flex flex-col gap-2">
<Label for="sp-name">{{ $t("new_playlist_name") }}</Label>
<Input
id="sp-name"
ref="nameInput"
v-model="playlistName"
@keyup.enter="doSave"
/>
</div>

<!-- Mode: Dynamic / Fixed -->
<div class="flex flex-col gap-2">
<Label>{{ $t("smart_playlist.mode") }}</Label>
<Tabs v-model="mode">
<TabsList class="h-8">
<TabsTrigger value="dynamic" class="text-xs px-4">
{{ $t("smart_playlist.dynamic") }}
</TabsTrigger>
<TabsTrigger value="fixed" class="text-xs px-4">
{{ $t("smart_playlist.fixed") }}
</TabsTrigger>
</TabsList>
</Tabs>
</div>

<!-- Track count (fixed mode only) -->
<div v-if="mode === 'fixed'" class="flex flex-col gap-2">
<Label for="sp-count">{{ $t("smart_playlist.track_count") }}</Label>
<NumberField
id="sp-count"
v-model="trackCount"
:min="1"
:max="2000"
class="max-w-[160px]"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
</div>

<SmartPlaylistRulesForm
:key="dialogKey"
ref="rulesForm"
@track-count-update="onTrackCountUpdate"
/>

<p v-if="isCountingTracks" class="text-sm text-muted-foreground">
</p>
<p v-else-if="matchingTrackCount !== null" class="text-sm text-muted-foreground">
~{{ matchingTrackCount }} {{ $t("tracks") }}
</p>
</div>

<DialogFooter>
<Button variant="outline" @click="showDialog = false">
{{ $t("close") }}
</Button>
<Button :disabled="!playlistName || isSaving" @click="doSave">
<span v-if="isSaving">{{ $t("smart_playlist.creating") }}</span>
<span v-else>{{ $t("settings.save") }}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { toast } from "vue-sonner";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import SmartPlaylistRulesForm from "@/components/smart_playlist/SmartPlaylistRulesForm.vue";
import api from "@/plugins/api";
import { type CreateSmartPlaylistEvent, eventbus } from "@/plugins/eventbus";
import { $t } from "@/plugins/i18n";
import router from "@/plugins/router";
import { store } from "@/plugins/store";

const showDialog = ref(false);
const dialogKey = ref(0);
const playlistName = ref("");
const mode = ref<"dynamic" | "fixed">("dynamic");
const trackCount = ref(100);
const isSaving = ref(false);
const matchingTrackCount = ref<number | null>(null);
const isCountingTracks = ref(false);
const nameInput = ref();
const rulesForm = ref<InstanceType<typeof SmartPlaylistRulesForm>>();

watch(showDialog, (open) => {
store.dialogActive = open;
if (open) {
nextTick(() => {
nameInput.value?.focus?.();
});
}
});

function onTrackCountUpdate(count: number | null, counting: boolean) {
matchingTrackCount.value = count;
isCountingTracks.value = counting;
}

async function doSave() {
if (!playlistName.value || isSaving.value) return;
isSaving.value = true;
showDialog.value = false;
try {
const finalRules = rulesForm.value!.getFinalRules();
if (mode.value === "dynamic") {
const playlist = await api.createSmartPlaylist(
playlistName.value,
finalRules,
true,
);
toast.success($t("smart_playlist.created"), {
action: {
label: $t("open_playlist"),
onClick: () => {
store.showFullscreenPlayer = false;
router.push({
name: "playlist",
params: { itemId: playlist.item_id, provider: "library" },
});
},
},
});
} else {
const playlist = await api.generateSmartPlaylist(
playlistName.value,
finalRules,
trackCount.value,
);
toast.success($t("playlist_created"), {
action: {
label: $t("open_playlist"),
onClick: () => {
store.showFullscreenPlayer = false;
router.push({
name: "playlist",
params: { itemId: playlist.item_id, provider: "library" },
});
},
},
});
}
} catch (e) {
toast.error(String(e));
} finally {
isSaving.value = false;
}
}

onMounted(() => {
eventbus.on("createSmartPlaylist", (_evt: CreateSmartPlaylistEvent) => {
playlistName.value = "";
mode.value = "dynamic";
trackCount.value = 100;
matchingTrackCount.value = null;
dialogKey.value++;
showDialog.value = true;
});
});

onBeforeUnmount(() => {
eventbus.off("createSmartPlaylist");
});
</script>
145 changes: 145 additions & 0 deletions src/layouts/default/EditSmartPlaylistDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!--
Dialog to edit the rules of an existing dynamic smart playlist.
-->
<template>
<Dialog v-model:open="showDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle class="mb-2">
{{ $t("smart_playlist.edit_rules") }}
</DialogTitle>
<DialogDescription>
{{ playlistName }}
</DialogDescription>
</DialogHeader>

<div
v-if="loading"
class="py-8 text-center text-muted-foreground text-sm"
>
{{ $t("loading") }}
</div>

<div v-else class="flex flex-col gap-4 py-2">
<SmartPlaylistRulesForm
ref="rulesForm"
:initial-rules="loadedRules"
:initial-artist-items="loadedArtistItems"
:initial-album-items="loadedAlbumItems"
@track-count-update="onTrackCountUpdate"
/>

<p v-if="isCountingTracks" class="text-sm text-muted-foreground">
</p>
<p v-else-if="matchingTrackCount !== null" class="text-sm text-muted-foreground">
~{{ matchingTrackCount }} {{ $t("tracks") }}
</p>
</div>

<DialogFooter>
<Button variant="outline" @click="showDialog = false">
{{ $t("close") }}
</Button>
<Button :disabled="loading || isSaving" @click="doSave">
<span v-if="isSaving">{{ $t("smart_playlist.saving") }}</span>
<span v-else>{{ $t("settings.save") }}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";
import { toast } from "vue-sonner";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import SmartPlaylistRulesForm from "@/components/smart_playlist/SmartPlaylistRulesForm.vue";
import api from "@/plugins/api";
import type { SmartPlaylistRules } from "@/plugins/api/interfaces";
import { $t } from "@/plugins/i18n";

export interface Props {
dbPlaylistId: string;
playlistName: string;
}

const props = defineProps<Props>();
const emit = defineEmits<{ (e: "saved"): void }>();

const showDialog = defineModel<boolean>("open", { default: false });

const loading = ref(false);
const isSaving = ref(false);
const matchingTrackCount = ref<number | null>(null);
const isCountingTracks = ref(false);
const rulesForm = ref<InstanceType<typeof SmartPlaylistRulesForm>>();

const loadedRules = ref<SmartPlaylistRules | null>(null);
const loadedArtistItems = ref<{ id: number; name: string }[]>([]);
const loadedAlbumItems = ref<{ id: number; name: string }[]>([]);

watch(showDialog, async (open) => {
if (open) {
loading.value = true;
loadedRules.value = null;
loadedArtistItems.value = [];
loadedAlbumItems.value = [];

const fetchedRules = await api.getSmartPlaylistRules(props.dbPlaylistId);

if (fetchedRules) {
await Promise.all([
...fetchedRules.artist_ids.map(async (id) => {
try {
const artist = await api.getArtist(String(id), "library");
loadedArtistItems.value.push({ id, name: artist.name });
} catch {
loadedArtistItems.value.push({ id, name: String(id) });
}
}),
...fetchedRules.album_ids.map(async (id) => {
try {
const album = await api.getAlbum(String(id), "library");
loadedAlbumItems.value.push({ id, name: album.name });
} catch {
loadedAlbumItems.value.push({ id, name: String(id) });
}
}),
]);
loadedRules.value = fetchedRules;
}
loading.value = false;
}
});

function onTrackCountUpdate(count: number | null, counting: boolean) {
matchingTrackCount.value = count;
isCountingTracks.value = counting;
}

async function doSave() {
if (isSaving.value) return;
isSaving.value = true;
try {
const finalRules = rulesForm.value!.getFinalRules();
await api.updateSmartPlaylistRules(props.dbPlaylistId, finalRules);
toast.success($t("settings.save"));
showDialog.value = false;
emit("saved");
} catch (e) {
toast.error(String(e));
} finally {
isSaving.value = false;
}
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ const radioModeSupported = computed(() => {
const item = currentTrack.value;
if (!item) return false;
// hide radio mode for dynamic playlists
const queue = store.activePlayer ? api.queues[store.activePlayer.player_id] : undefined;
const queue = store.activePlayer
? api.queues[store.activePlayer.player_id]
: undefined;
if (isQueueDynamicPlaylist(queue)) return false;
for (const provId of item.provider_mappings) {
if (
Expand Down
9 changes: 4 additions & 5 deletions src/layouts/default/PlayerOSD/PlayerControlBtn/RepeatBtn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ defineOptions({ inheritAttrs: false });
import Icon, { IconProps } from "@/components/Icon.vue";
import { getValueFromSources } from "@/helpers/utils";
import api from "@/plugins/api";
import {
PlayerQueue,
RepeatMode,
} from "@/plugins/api/interfaces";
import { PlayerQueue, RepeatMode } from "@/plugins/api/interfaces";
import { isQueueDynamicPlaylist } from "@/plugins/api/helpers";
import { computed } from "vue";
import { IconRepeat, IconRepeatOff, IconRepeatOnce } from "@tabler/icons-vue";
Expand All @@ -72,5 +69,7 @@ const isLoading = computed(() => {
);
});

const isSingleDynamicPlaylist = computed(() => isQueueDynamicPlaylist(compProps.playerQueue));
const isSingleDynamicPlaylist = computed(() =>
isQueueDynamicPlaylist(compProps.playerQueue),
);
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,7 @@ const isLoading = computed(() => {
);
});

const isSingleDynamicPlaylist = computed(() => isQueueDynamicPlaylist(compProps.playerQueue));
const isSingleDynamicPlaylist = computed(() =>
isQueueDynamicPlaylist(compProps.playerQueue),
);
</script>
Loading
Loading