New Webserver and WebApp (#942)

* Replace the WCF webserver with an ASP.NET Core webserver (VocaluxeWeb)

- New project VocaluxeWeb: ASP.NET Core 2.3 (Kestrel) on .NET Framework 4.8.1, HTTP or HTTPS
  with a self-signed certificate, REST API (/api/v1) with Swagger, live events (Server-Sent Events)
- Access to the game only through IVocaluxeRemote; requests run on the main thread once per frame
- Sessions with roles (guest, player, admin) and configurable permissions, PINs hashed with PBKDF2
- Queue as a playlist with requesters and singers, fair order (one song per person and round)
- Admin: settings, server status, logged-in devices, activate/deactivate and delete profiles
- Profile pictures: own photo or one of the Vocaluxe avatars
- Game: WebApp queue screen (song options, playlist group) to start the next song with the chosen
  singers, return to it after singing, warning when the webserver is off, remote keyboard
- Old WCF server, WebserverInitalConfig and URL reservations removed; libraries moved to libs/managed
- Unit tests for PIN hashing, settings and permissions, queue order and device names
- AppVeyor: Visual Studio 2022 image and Node.js 24 (the build compiles the WebApp)
- Documentation in VocaluxeWeb/README.md

* New WebApp (Vue 3, TypeScript, Vite) replacing the old jQuery Mobile website

- Login with profile and PIN, create profiles
- Songs with search, filters, sorting and 30 second previews
- Queue: add songs with game mode, audio and singers, edit, reorder, start in the game
- Playlists: create, edit, add to the queue
- Now playing with live updates, remote control and keyboard
- Players: own profile with picture (photo or Vocaluxe avatar), roles for admins
- Admin settings: permissions, server status, devices, profiles
- Help page, responsive layout for phones, tablets and desktops, light and dark mode
- Texts in WebApp/src/locales/en.json, translated with Transifex
- Old website and PhoneGap config removed

---------

Co-authored-by: Florian Ostertag <flokuep@users.noreply.github.com>
This commit is contained in:
McSchneidarandFlorian Ostertag authored and GitHub committed 2026-10-26 05:40:11 +01:00
1 parent e3e9d1d6ae
commit 336d08040e
186 files changed
+14971 -6988

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+1
View File
@@ -0,0 +1 @@
24
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#14121c" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" href="/icon.png" />
<link rel="apple-touch-icon" href="/icon.png" />
<title>Vocaluxe</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1777
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "vocaluxe-webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"api:generate": "npx --yes openapi-typescript@7 ${VOCALUXE_URL:-http://localhost:3000}/swagger/v1/swagger.json -o src/api/schema.d.ts"
},
"dependencies": {
"openapi-fetch": "^0.17.0",
"vue": "^3.5.44",
"vue-i18n": "^11.4.16",
"vue-router": "^5.4.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@vitejs/plugin-vue": "^6.0.10",
"typescript": "~6.0.3",
"vite": "^8.3.5",
"vue-tsc": "^3.3.13"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"name": "Vocaluxe",
"short_name": "Vocaluxe",
"start_url": "/",
"display": "standalone",
"background_color": "#14121c",
"theme_color": "#14121c",
"icons": [
{ "src": "/icon.png", "sizes": "132x132", "type": "image/png" }
]
}
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { toasts } from '@/stores/toast'
</script>
<template>
<RouterView />
<div class="toasts" aria-live="polite">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :class="`toast--${t.kind}`">{{ t.text }}</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toasts {
position: fixed;
left: 50%;
bottom: calc(var(--tabbar-height) + var(--nowbar-height) + 16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
display: grid;
gap: 8px;
width: min(92vw, 420px);
z-index: 50;
pointer-events: none;
}
@media (min-width: 768px) {
.toasts {
bottom: calc(var(--nowbar-height) + 24px);
}
}
@media (min-width: 1200px) {
.toasts {
bottom: 24px;
}
}
.toast {
padding: 12px 16px;
border-radius: var(--radius);
background: var(--surface-raised);
color: var(--text);
box-shadow: var(--shadow);
text-align: center;
}
.toast--error {
background: var(--error);
color: #fff;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(8px);
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.2s ease;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
// Typed client for the Vocaluxe REST API. The types in schema.d.ts are generated from Swagger:
// start Vocaluxe with the webserver on and run `npm run api:generate`.
import createClient from 'openapi-fetch'
import type { components, paths } from './schema'
type Schemas = components['schemas']
export type ServerInfo = Schemas['ServerInfoDto']
export type State = Schemas['StateDto']
export type Profile = Schemas['ProfileDto']
export type Session = Schemas['SessionDto']
export type Permission = Schemas['EPermission']
export type Role = Schemas['ERemoteRole']
export type Song = Schemas['SongDto']
export type SongPage = Schemas['SongPageDto']
export type SongFacets = Schemas['SongFacetsDto']
export type SongSort = Schemas['ESongSort']
export type PlaylistSummary = Schemas['PlaylistSummaryDto']
export type Playlist = Schemas['PlaylistDto']
export type QueueEntry = Schemas['QueueEntryDto']
export type RemoteKey = Schemas['ERemoteKey']
export type GameMode = Schemas['ESongGameMode']
export type AudioMode = Schemas['ESongAudioMode']
export type QueueOptions = Schemas['QueueUpdateDto']
export type AdminStatus = Schemas['AdminStatusDto']
export type AdminSession = Schemas['AdminSessionDto']
export type Avatar = Schemas['AvatarDto']
/** Live event "notification" (not part of the REST API, so not in the generated schema). */
export interface Notification {
type: 'queueAdded' | 'queueAddedMany'
profile: Schemas['ProfileRefDto']
song?: Song
count: number
}
export type AdminSettings = Schemas['AdminSettingsDto']
export interface SongQuery {
query?: string
language?: string
genre?: string
decade?: number
duet?: boolean
sort?: SongSort
page?: number
pageSize?: number
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string
) {
super(message)
}
}
const client = createClient<paths>({ credentials: 'same-origin' })
async function unwrap<T>(request: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
let result
try {
result = await request
} catch {
throw new ApiError(0, 'offline')
}
if (!result.response.ok) {
const title = (result.error as { title?: string } | undefined)?.title
throw new ApiError(result.response.status, title ?? result.response.statusText)
}
return result.data as T
}
const id = (value: number) => ({ params: { path: { id: value } } })
const guid = (value: string) => ({ params: { path: { id: value } } })
export const api = {
server: () => unwrap(client.GET('/api/v1/server')),
state: () => unwrap(client.GET('/api/v1/state')),
session: () => unwrap(client.GET('/api/v1/session')),
login: (profileId: string, pin?: string) => unwrap(client.POST('/api/v1/session', { body: { profileId, pin } })),
logout: () => unwrap(client.DELETE('/api/v1/session')),
profiles: () => unwrap(client.GET('/api/v1/profiles')),
createProfile: (name: string, pin?: string) => unwrap(client.POST('/api/v1/profiles', { body: { name, pin } })),
updateProfile: (profileId: string, change: Schemas['ProfileUpdateDto']) =>
unwrap(client.PUT('/api/v1/profiles/{id}', { ...guid(profileId), body: change })),
selectGameAvatar: (profileId: string, avatarId: number) =>
unwrap(client.PUT('/api/v1/profiles/{id}/game-avatar', { ...guid(profileId), body: { avatarId } })),
setRole: (profileId: string, role: Role) =>
unwrap(client.PUT('/api/v1/profiles/{id}/role', { ...guid(profileId), body: { role } })),
async setAvatar(profileId: string, picture: Blob): Promise<Profile> {
const response = await fetch(`/api/v1/profiles/${profileId}/avatar`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': picture.type || 'application/octet-stream' },
body: picture
})
const body = await response.json().catch(() => null)
if (!response.ok) throw new ApiError(response.status, body?.title ?? response.statusText)
return body as Profile
},
avatars: () => unwrap(client.GET('/api/v1/avatars')),
songs: (query: SongQuery) => unwrap(client.GET('/api/v1/songs', { params: { query } })),
facets: () => unwrap(client.GET('/api/v1/songs/facets')),
playlists: () => unwrap(client.GET('/api/v1/playlists')),
playlist: (playlistId: number) => unwrap(client.GET('/api/v1/playlists/{id}', id(playlistId))),
createPlaylist: (name: string) => unwrap(client.POST('/api/v1/playlists', { body: { name } })),
renamePlaylist: (playlistId: number, name: string) =>
unwrap(client.PUT('/api/v1/playlists/{id}', { ...id(playlistId), body: { name } })),
deletePlaylist: (playlistId: number) => unwrap(client.DELETE('/api/v1/playlists/{id}', id(playlistId))),
addToPlaylist: (playlistId: number, songId: number) =>
unwrap(client.POST('/api/v1/playlists/{id}/songs', { ...id(playlistId), body: { songId } })),
removeFromPlaylist: (playlistId: number, position: number) =>
unwrap(client.DELETE('/api/v1/playlists/{id}/songs/{position}', { params: { path: { id: playlistId, position } } })),
moveInPlaylist: (playlistId: number, position: number, newPosition: number) =>
unwrap(
client.PUT('/api/v1/playlists/{id}/songs/{position}/position', {
params: { path: { id: playlistId, position } },
body: { position: newPosition }
})
),
queue: () => unwrap(client.GET('/api/v1/queue')),
enqueue: (songId: number, options: QueueOptions = {}) => unwrap(client.POST('/api/v1/queue', { body: { songId, ...options } })),
updateQueueEntry: (entryId: string, options: QueueOptions) =>
unwrap(client.PUT('/api/v1/queue/{id}', { params: { path: { id: entryId } }, body: options })),
dequeue: (entryId: string) => unwrap(client.DELETE('/api/v1/queue/{id}', { params: { path: { id: entryId } } })),
moveInQueue: (entryId: string, newPosition: number) =>
unwrap(client.PUT('/api/v1/queue/{id}/position', { params: { path: { id: entryId } }, body: { position: newPosition } })),
clearQueue: () => unwrap(client.DELETE('/api/v1/queue')),
importToQueue: (playlistId: number, positions?: number[]) => unwrap(client.POST('/api/v1/queue/import', { body: { playlistId, positions } })),
startQueueEntry: (entryId: string) => unwrap(client.POST('/api/v1/queue/{id}/start', { params: { path: { id: entryId } } })),
sendKey: (key: RemoteKey) => unwrap(client.POST('/api/v1/input/key', { body: { key } })),
sendText: (text: string) => unwrap(client.POST('/api/v1/input/text', { body: { text } })),
adminSettings: () => unwrap(client.GET('/api/v1/admin/settings')),
saveAdminSettings: (settings: AdminSettings) => unwrap(client.PUT('/api/v1/admin/settings', { body: settings })),
adminStatus: () => unwrap(client.GET('/api/v1/admin/status')),
adminProfiles: () => unwrap(client.GET('/api/v1/admin/profiles')),
setProfileActive: (profileId: string, active: boolean) =>
unwrap(client.PUT('/api/v1/admin/profiles/{id}/active', { ...guid(profileId), body: { active } })),
deleteProfile: (profileId: string) => unwrap(client.DELETE('/api/v1/admin/profiles/{id}', guid(profileId))),
adminSessions: () => unwrap(client.GET('/api/v1/admin/sessions')),
deleteSession: (sessionId: string) => unwrap(client.DELETE('/api/v1/admin/sessions/{id}', guid(sessionId)))
}
export const audioUrl = (songId: number) => `/api/v1/songs/${songId}/audio`
export const eventsUrl = '/api/v1/events'
export const maxPlayers = 6
export const coverUrl = (songId: number, size = 96) => `/api/v1/songs/${songId}/cover?size=${size}`
export const gameAvatarUrl = (avatarId: number, size = 200) => `/api/v1/avatars/${avatarId}?size=${size}`
export const avatarUrl = (profileId: string, size = 200, version = '') =>
`/api/v1/profiles/${profileId}/avatar?size=${size}${version ? `&v=${version}` : ''}`
+1859
View File
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
// Simple stroke icons (24x24). Kept inline so the WebApp needs no icon font.
const icons = {
mic: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z M5 11a7 7 0 0 0 14 0 M12 18v3 M8 21h8',
music: 'M9 18V5l11-2v13 M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0z M20 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z',
queue: 'M3 6h13 M3 12h13 M3 18h9 M18 15l4 3-4 3z',
users: 'M16 20v-1a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v1 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M22 20v-1a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z M21 21l-4.35-4.35',
plus: 'M12 5v14 M5 12h14',
trash: 'M3 6h18 M8 6V4h8v2 M6 6l1 14h10l1-14',
up: 'M12 19V5 M5 12l7-7 7 7',
down: 'M12 5v14 M19 12l-7 7-7-7',
left: 'M15 18l-6-6 6-6',
right: 'M9 18l6-6-6-6',
chevronUp: 'M18 15l-6-6-6 6',
chevronDown: 'M6 9l6 6 6-6',
back: 'M19 12H5 M12 19l-7-7 7-7',
keyboard: 'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M7 14h10',
playlist: 'M3 6h12 M3 12h12 M3 18h8 M17 18V9l4-1',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9',
edit: 'M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
camera: 'M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z M12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
close: 'M18 6L6 18 M6 6l12 12',
check: 'M20 6L9 17l-5-5',
globe: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M2 12h20 M12 2a15 15 0 0 1 0 20 M12 2a15 15 0 0 0 0 20',
code: 'M16 18l6-6-6-6 M8 6l-6 6 6 6',
lock: 'M5 11h14v10H5z M8 11V7a4 4 0 0 1 8 0v4',
duet: 'M8 4a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V7a3 3 0 0 0-3-3z M16 4a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V7a3 3 0 0 0-3-3z M8 14v6 M16 14v6',
filter: 'M22 3H2l8 9.46V19l4 2v-8.54z',
play: 'M7 4l13 8-13 8z',
stop: 'M6 6h12v12H6z',
sort: 'M3 6h10 M3 12h7 M3 18h4 M17 4v16 M14 17l3 3 3-3',
help: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3 M12 17h.01'
} as const
export type IconName = keyof typeof icons
defineProps<{ name: IconName; size?: number }>()
</script>
<template>
<svg
:width="size ?? 24"
:height="size ?? 24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path :d="icons[name]" />
</svg>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
// Bottom sheet on phones, centered dialog on larger screens. Based on <dialog> for focus handling and Escape.
import { onMounted, ref, watch } from 'vue'
import AppIcon from './AppIcon.vue'
const props = defineProps<{ open: boolean; title?: string }>()
const emit = defineEmits<{ close: [] }>()
const dialog = ref<HTMLDialogElement>()
function sync() {
if (!dialog.value) return
if (props.open && !dialog.value.open) dialog.value.showModal()
if (!props.open && dialog.value.open) dialog.value.close()
}
onMounted(sync)
watch(() => props.open, sync)
function onClick(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog ref="dialog" class="sheet" @close="emit('close')" @click="onClick">
<div class="sheet-body">
<header class="sheet-header">
<h2>{{ title }}</h2>
<button class="icon-btn" type="button" :aria-label="$t('common.close')" @click="emit('close')">
<AppIcon name="close" />
</button>
</header>
<slot />
</div>
</dialog>
</template>
<style scoped>
.sheet {
width: 100%;
max-width: 100%;
margin: auto 0 0;
padding: 0;
border: 0;
border-radius: 20px 20px 0 0;
background: var(--surface);
color: var(--text);
max-height: 85dvh;
}
.sheet::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.sheet-body {
display: grid;
gap: var(--gutter);
padding: var(--gutter) var(--gutter) calc(var(--gutter) + env(safe-area-inset-bottom));
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sheet-header h2 {
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 768px) {
.sheet {
width: 480px;
margin: auto;
border-radius: 20px;
}
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { live } from '@/stores/live'
import { session } from '@/stores/session'
import SongCover from './SongCover.vue'
const props = defineProps<{ upNextCount?: number }>()
const { t, te } = useI18n()
const state = computed(() => live.state)
const entries = computed(() => live.queue)
const screenName = computed(() => {
const screen = state.value?.screen
if (!screen) return '–'
return te(`screens.${screen}`) ? t(`screens.${screen}`) : screen
})
const upNext = computed(() => (entries.value ?? []).slice(0, props.upNextCount ?? 5))
</script>
<template>
<section class="now">
<div class="current card">
<SongCover v-if="state?.currentSong" :song-id="state.currentSong.id" :has-cover="state.currentSong.hasCover" :size="72" />
<div class="current-text">
<span class="muted small">{{ screenName }}</span>
<template v-if="state?.currentSong">
<strong class="ellipsis">{{ state.currentSong.title }}</strong>
<span class="muted ellipsis">{{ state.currentSong.artist }}</span>
</template>
<span v-else class="muted">{{ t('now.noSong') }}</span>
</div>
</div>
<div class="up-next">
<h2>{{ t('now.upNext') }}</h2>
<p v-if="upNext.length === 0" class="muted">{{ t('queue.empty') }}</p>
<ol v-else class="list">
<li v-for="entry in upNext" :key="entry.id" class="row">
<SongCover :song-id="entry.song.id" :has-cover="entry.song.hasCover" :size="40" />
<span class="row-text">
<span class="row-title">{{ entry.song.title }}</span>
<span class="row-sub">
{{ entry.song.artist }}
<template v-if="entry.requestedBy">
· {{ entry.requestedBy.name }}<template v-if="entry.requestedBy.id === session.profile.value?.id"> ({{ t('queue.you') }})</template>
</template>
</span>
</span>
</li>
</ol>
</div>
</section>
</template>
<style scoped>
.now {
display: grid;
gap: var(--gutter);
align-content: start;
}
.current {
display: flex;
align-items: center;
gap: 14px;
}
.current-text {
display: grid;
min-width: 0;
}
.small {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.up-next {
display: grid;
gap: 10px;
}
.up-next .row {
min-height: 56px;
}
.up-next p {
margin: 0;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { avatarUrl, type Profile } from '@/api/client'
import { session } from '@/stores/session'
const props = defineProps<{ profile: Pick<Profile, 'id' | 'name' | 'hasAvatar'>; size?: number }>()
const failed = ref(false)
const pixels = computed(() => props.size ?? 48)
const version = computed(() => (props.profile.id === session.profile.value?.id ? session.state.avatarVersion : ''))
const src = computed(() => avatarUrl(props.profile.id, pixels.value * 2, version.value))
const initials = computed(() =>
props.profile.name
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
)
// A stable color per profile for the placeholder.
const hue = computed(() => [...props.profile.id].reduce((sum, c) => sum + c.charCodeAt(0), 0) % 360)
watch(src, () => (failed.value = false))
</script>
<template>
<div
class="avatar"
:style="{ width: `${pixels}px`, height: `${pixels}px`, fontSize: `${pixels / 2.6}px`, background: `hsl(${hue} 55% 42%)` }"
>
<img v-if="profile.hasAvatar && !failed" :src="src" alt="" loading="lazy" @error="failed = true" />
<span v-else>{{ initials }}</span>
</div>
</template>
<style scoped>
.avatar {
flex: none;
display: grid;
place-items: center;
border-radius: 50%;
overflow: hidden;
color: #fff;
font-weight: 700;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script setup lang="ts">
// Game mode, audio mode and singers of a wish. Only shows what the song supports.
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, maxPlayers, type AudioMode, type GameMode, type Profile, type Song } from '@/api/client'
import ProfileAvatar from './ProfileAvatar.vue'
export interface QueueChoice {
gameMode: GameMode
audioMode: AudioMode
playerIds: string[]
}
const props = defineProps<{ song: Song }>()
const choice = defineModel<QueueChoice>({ required: true })
const { t } = useI18n()
let profileCache: Promise<Profile[]> | null = null
const profiles = ref<Profile[]>([])
onMounted(async () => {
profileCache ??= api.profiles().catch(() => [])
profiles.value = await profileCache
})
const gameModes = computed(() => props.song.gameModes)
const audioModes = computed(() => props.song.audioModes)
function togglePlayer(id: string) {
const ids = choice.value.playerIds
if (ids.includes(id)) choice.value = { ...choice.value, playerIds: ids.filter((p) => p !== id) }
else if (ids.length < maxPlayers) choice.value = { ...choice.value, playerIds: [...ids, id] }
}
</script>
<template>
<div class="options">
<fieldset v-if="gameModes.length > 1" class="group">
<legend>{{ t('songs.gameMode') }}</legend>
<div class="segments">
<label v-for="mode in gameModes" :key="mode" class="segment" :class="{ active: choice.gameMode === mode }">
<input v-model="choice.gameMode" type="radio" :value="mode" class="visually-hidden" />
{{ t(`gameModes.${mode}`) }}
</label>
</div>
</fieldset>
<fieldset v-if="audioModes.length > 1" class="group">
<legend>{{ t('songs.audioMode') }}</legend>
<div class="segments">
<label v-for="mode in audioModes" :key="mode" class="segment" :class="{ active: choice.audioMode === mode }">
<input v-model="choice.audioMode" type="radio" :value="mode" class="visually-hidden" />
{{ t(`audioModes.${mode}`) }}
</label>
</div>
</fieldset>
<fieldset class="group">
<legend>{{ t('songs.players') }}</legend>
<p class="hint muted">{{ t('songs.playersHint') }}</p>
<div class="players">
<button
v-for="p in profiles"
:key="p.id"
type="button"
class="player"
:class="{ active: choice.playerIds.includes(p.id) }"
:aria-pressed="choice.playerIds.includes(p.id)"
:disabled="!choice.playerIds.includes(p.id) && choice.playerIds.length >= maxPlayers"
@click="togglePlayer(p.id)"
>
<ProfileAvatar :profile="p" :size="28" />
<span>{{ p.name }}</span>
<span v-if="choice.playerIds.includes(p.id)" class="order">{{ choice.playerIds.indexOf(p.id) + 1 }}</span>
</button>
</div>
</fieldset>
</div>
</template>
<style scoped>
.options {
display: grid;
gap: 16px;
}
.group {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.group legend {
padding: 0;
margin-bottom: 8px;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.hint {
margin: -6px 0 0;
font-size: 0.8rem;
}
.segments {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.segment {
position: relative;
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
font-weight: 600;
}
.segment.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.players {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.player {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 4px 12px 4px 4px;
border: 1px solid var(--border);
border-radius: 999px;
background: transparent;
}
.player.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.order {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { coverUrl } from '@/api/client'
import AppIcon from './AppIcon.vue'
const props = defineProps<{ songId: number; hasCover?: boolean; size?: number }>()
const failed = ref(false)
const loaded = ref(false)
const pixels = computed(() => props.size ?? 48)
// Request twice the displayed size for sharp images on high density screens.
const src = computed(() => coverUrl(props.songId, pixels.value * 2))
watch(src, () => {
failed.value = false
loaded.value = false
})
</script>
<template>
<div class="cover" :style="{ width: `${pixels}px`, height: `${pixels}px` }">
<img
v-if="hasCover !== false && !failed"
:src="src"
:class="{ loaded }"
alt=""
loading="lazy"
decoding="async"
@load="loaded = true"
@error="failed = true"
/>
<AppIcon v-else name="music" :size="Math.round(pixels / 2.4)" />
</div>
</template>
<style scoped>
.cover {
flex: none;
display: grid;
place-items: center;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(135deg, var(--surface-raised), var(--surface-hover));
color: var(--text-muted);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.2s ease;
}
.cover img.loaded {
opacity: 1;
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type PlaylistSummary, type Song } from '@/api/client'
import { setQueue } from '@/stores/live'
import { preview, togglePreview } from '@/stores/preview'
import { session } from '@/stores/session'
import { showError, toast } from '@/stores/toast'
import AppIcon from './AppIcon.vue'
import AppSheet from './AppSheet.vue'
import QueueOptions, { type QueueChoice } from './QueueOptions.vue'
import SongCover from './SongCover.vue'
const props = defineProps<{ song: Song | null }>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const playlists = ref<PlaylistSummary[] | null>(null)
const choosingPlaylist = ref(false)
const busy = ref(false)
const choice = ref<QueueChoice>({ gameMode: 'Normal', audioMode: 'Normal', playerIds: [] })
watch(
() => props.song,
(song) => {
choosingPlaylist.value = false
if (song) choice.value = { gameMode: song.gameModes[0] ?? 'Normal', audioMode: 'Normal', playerIds: [] }
}
)
async function addToQueue() {
if (!props.song) return
busy.value = true
try {
setQueue(await api.enqueue(props.song.id, choice.value))
toast(t('songs.addedToQueue', { title: props.song.title }))
emit('close')
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
async function choosePlaylist() {
choosingPlaylist.value = true
playlists.value = null
playlists.value = await api.playlists().catch((e) => {
showError(e)
return []
})
}
async function addToPlaylist(playlist: PlaylistSummary) {
if (!props.song) return
busy.value = true
try {
await api.addToPlaylist(playlist.id, props.song.id)
toast(t('songs.addedToPlaylist', { name: playlist.name }))
emit('close')
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
</script>
<template>
<AppSheet :open="song !== null" :title="choosingPlaylist ? t('songs.addToPlaylist') : ''" @close="emit('close')">
<template v-if="song && !choosingPlaylist">
<div class="detail">
<SongCover :song-id="song.id" :has-cover="song.hasCover" :size="104" />
<div class="detail-text">
<h2>{{ song.title }}</h2>
<p class="muted">{{ song.artist }}</p>
<p class="badges">
<span v-if="song.year" class="badge">{{ song.year }}</span>
<span v-if="song.language" class="badge">{{ song.language }}</span>
<span v-if="song.genre" class="badge">{{ song.genre }}</span>
<span v-if="song.isDuet" class="badge badge--accent">{{ t('songs.duet') }}</span>
</p>
<button v-if="song.hasAudio" class="btn preview" :class="{ playing: preview.songId === song.id }" @click="togglePreview(song)">
<AppIcon :name="preview.songId === song.id ? 'stop' : 'play'" :size="18" />
{{ preview.songId === song.id ? t('songs.stopPreview') : t('songs.preview') }}
</button>
</div>
</div>
<QueueOptions v-if="session.can('AddToQueue')" v-model="choice" :song="song" />
<div class="actions">
<button v-if="session.can('AddToQueue')" class="btn btn--primary btn--block" :disabled="busy" @click="addToQueue">
<AppIcon name="queue" /> {{ t('songs.addToQueue') }}
</button>
<button v-if="session.can('EditPlaylists')" class="btn btn--block" :disabled="busy" @click="choosePlaylist">
<AppIcon name="playlist" /> {{ t('songs.addToPlaylist') }}
</button>
</div>
</template>
<template v-else-if="song">
<p v-if="playlists === null" class="muted">{{ t('common.loading') }}</p>
<p v-else-if="playlists.length === 0" class="muted">{{ t('songs.noPlaylists') }}</p>
<ul v-else class="list picker">
<li v-for="p in playlists" :key="p.id">
<button class="row" :disabled="busy" @click="addToPlaylist(p)">
<AppIcon name="playlist" />
<span class="row-text">
<span class="row-title">{{ p.name }}</span>
<span class="row-sub">{{ t('playlists.songs', p.songCount) }}</span>
</span>
</button>
</li>
</ul>
<button class="btn" @click="choosingPlaylist = false"><AppIcon name="back" /> {{ t('common.back') }}</button>
</template>
</AppSheet>
</template>
<style scoped>
.detail {
display: flex;
gap: 16px;
align-items: center;
}
.detail-text {
min-width: 0;
display: grid;
gap: 4px;
justify-items: start;
}
.detail-text p {
margin: 0;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.preview {
min-height: 36px;
margin-top: 4px;
padding: 0 14px;
}
.preview.playing {
background: var(--accent-soft);
color: var(--accent);
}
.actions {
display: grid;
gap: 10px;
}
.picker {
background: var(--surface-raised);
max-height: 50dvh;
overflow-y: auto;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+41
View File
@@ -0,0 +1,41 @@
// Translations live in src/locales/<lang>.json as flat key/value files (Transifex format KEYVALUEJSON).
// en.json is the source; every file that Transifex adds there shows up as a language automatically.
import { createI18n } from 'vue-i18n'
import { storage } from '@/stores/storage'
const files = import.meta.glob<Record<string, string>>('../locales/*.json', { eager: true, import: 'default' })
const messages: Record<string, Record<string, string>> = {}
for (const [path, content] of Object.entries(files)) {
const code = path.replace(/^.*\/(.+)\.json$/, '$1')
messages[code] = content
}
export const languages = Object.keys(messages)
.map((code) => ({ code, name: messages[code]['language.name'] ?? code }))
.sort((a, b) => a.name.localeCompare(b.name))
const languageKey = 'vlx.language'
function initialLanguage(): string {
const saved = storage.get(languageKey)
if (saved && messages[saved]) return saved
const browser = navigator.language.toLowerCase()
return Object.keys(messages).find((code) => browser === code || browser.startsWith(`${code}-`)) ?? 'en'
}
export const i18n = createI18n({
legacy: false,
flatJson: true,
locale: initialLanguage(),
fallbackLocale: 'en',
messages
})
document.documentElement.lang = i18n.global.locale.value
export function setLanguage(code: string) {
i18n.global.locale.value = code
document.documentElement.lang = code
storage.set(languageKey, code)
}
+304
View File
@@ -0,0 +1,304 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import AppIcon, { type IconName } from '@/components/AppIcon.vue'
import NowPanel from '@/components/NowPanel.vue'
import ProfileAvatar from '@/components/ProfileAvatar.vue'
import SongCover from '@/components/SongCover.vue'
import { connectLive, disconnectLive, live } from '@/stores/live'
import { session } from '@/stores/session'
const { t, te } = useI18n()
const route = useRoute()
const state = computed(() => live.state)
interface Tab {
to: string
label: string
short?: string
icon: IconName
matches: string[]
visible?: () => boolean
}
const allTabs: Tab[] = [
{ to: '/now', label: 'nav.now', icon: 'mic', matches: ['now'] },
{ to: '/songs', label: 'nav.songs', icon: 'music', matches: ['songs'] },
{ to: '/queue', label: 'nav.queue', short: 'nav.queueShort', icon: 'queue', matches: ['queue'] },
{ to: '/remote', label: 'nav.remote', short: 'nav.remoteShort', icon: 'keyboard', matches: ['remote'], visible: () => session.can('UseKeyboard') },
{ to: '/players', label: 'nav.players', icon: 'users', matches: ['players'] },
{ to: '/more', label: 'nav.more', icon: 'more', matches: ['more', 'playlists', 'playlist', 'admin', 'help'] }
]
const tabs = computed(() => allTabs.filter((tab) => !tab.visible || tab.visible()))
const activeTab = computed(() => tabs.value.find((tab) => tab.matches.includes(String(route.name))))
// Show the connection warning only if the connection stays away for a moment.
const offline = ref(false)
let offlineTimer: number | undefined
watch(
() => live.started && !live.connected,
(lost) => {
window.clearTimeout(offlineTimer)
if (lost) offlineTimer = window.setTimeout(() => (offline.value = true), 2000)
else offline.value = false
}
)
onMounted(connectLive)
onUnmounted(() => {
window.clearTimeout(offlineTimer)
disconnectLive()
})
const screenName = computed(() => {
const screen = state.value?.screen
return screen && te(`screens.${screen}`) ? t(`screens.${screen}`) : (screen ?? '')
})
</script>
<template>
<div class="shell">
<nav class="sidebar" :aria-label="t('nav.more')">
<div class="brand">
<img src="/icon.png" alt="" width="32" height="32" />
<span>Vocaluxe</span>
</div>
<RouterLink v-for="tab in tabs" :key="tab.to" :to="tab.to" class="side-link" :class="{ active: activeTab === tab }">
<AppIcon :name="tab.icon" /> {{ t(tab.label) }}
</RouterLink>
<RouterLink v-if="session.profile.value" to="/players" class="me">
<ProfileAvatar :profile="session.profile.value" :size="32" />
<span class="row-title">{{ session.profile.value.name }}</span>
</RouterLink>
</nav>
<div v-if="offline" class="offline" role="status">{{ t('live.reconnecting') }}</div>
<main class="content">
<RouterView v-slot="{ Component }">
<component :is="Component" />
</RouterView>
</main>
<aside class="panel">
<NowPanel :up-next-count="8" />
</aside>
<RouterLink v-if="route.name !== 'now'" to="/now" class="nowbar">
<SongCover v-if="state?.currentSong" :song-id="state.currentSong.id" :has-cover="state.currentSong.hasCover" :size="40" />
<AppIcon v-else name="mic" />
<span class="row-text">
<span class="row-title">{{ state?.currentSong?.title ?? screenName }}</span>
<span class="row-sub">{{ state?.currentSong ? `${state.currentSong.artist} · ${screenName}` : t('now.noSong') }}</span>
</span>
</RouterLink>
<nav class="tabbar" :aria-label="t('nav.more')">
<RouterLink v-for="tab in tabs" :key="tab.to" :to="tab.to" class="tab" :class="{ active: activeTab === tab }">
<AppIcon :name="tab.icon" />
<span class="tab-label">{{ t(tab.short ?? tab.label) }}</span>
</RouterLink>
</nav>
</div>
</template>
<style scoped>
.shell {
min-height: 100dvh;
padding-top: env(safe-area-inset-top);
padding-bottom: calc(var(--tabbar-height) + var(--nowbar-height) + env(safe-area-inset-bottom));
}
.sidebar,
.panel {
display: none;
}
.content {
min-width: 0;
}
/* Phone: tab bar at the bottom, now-playing bar above it */
.tabbar {
position: fixed;
inset: auto 0 0 0;
z-index: 20;
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
height: calc(var(--tabbar-height) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: var(--surface);
border-top: 1px solid var(--border);
}
.tab {
display: grid;
place-items: center;
align-content: center;
gap: 2px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.7rem;
font-weight: 600;
}
.tab.active {
color: var(--accent);
}
.tab-label {
max-width: 100%;
padding: 0 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.65rem;
}
.offline {
position: sticky;
top: 0;
z-index: 30;
padding: 8px 16px;
background: var(--error);
color: #fff;
text-align: center;
font-size: 0.875rem;
font-weight: 600;
}
@media (min-width: 768px) {
.offline {
grid-column: 2 / -1;
}
}
.nowbar {
position: fixed;
left: 8px;
right: 8px;
bottom: calc(var(--tabbar-height) + env(safe-area-inset-bottom) + 6px);
z-index: 19;
display: flex;
align-items: center;
gap: 12px;
height: calc(var(--nowbar-height) - 6px);
padding: 0 14px 0 6px;
border-radius: var(--radius);
background: var(--surface-raised);
box-shadow: var(--shadow);
color: inherit;
text-decoration: none;
}
/* Tablet: navigation on the side */
@media (min-width: 768px) {
.shell {
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
padding-bottom: calc(var(--nowbar-height) + 12px);
}
.tabbar {
display: none;
}
.content {
grid-column: 2;
}
.sidebar {
grid-row: 1 / span 2;
position: sticky;
top: 0;
height: 100dvh;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 12px;
background: var(--surface);
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 16px;
font-weight: 700;
font-size: 1.15rem;
}
.brand img {
border-radius: 8px;
}
.side-link {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 0 12px;
border-radius: var(--radius-small);
color: var(--text-muted);
text-decoration: none;
font-weight: 600;
}
.side-link:hover {
background: var(--surface-hover);
}
.side-link.active {
background: var(--accent-soft);
color: var(--accent);
}
.me {
margin-top: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: var(--radius-small);
text-decoration: none;
min-width: 0;
}
.me:hover {
background: var(--surface-hover);
}
.nowbar {
left: calc(var(--sidebar-width) + 16px);
right: 16px;
bottom: 12px;
}
}
/* Desktop: the current song and the queue stay visible on the right */
@media (min-width: 1200px) {
.shell {
grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--panel-width);
padding-bottom: 0;
}
.panel {
display: block;
position: sticky;
top: 0;
height: 100dvh;
overflow-y: auto;
padding: var(--gutter);
border-left: 1px solid var(--border);
grid-column: 3;
grid-row: 1 / span 2;
}
.nowbar {
display: none;
}
}
</style>
+227
View File
@@ -0,0 +1,227 @@
{
"admin.active": "Active",
"admin.address": "Address",
"admin.allowProfileCreation": "Allow new profiles on the login page",
"admin.connected": "Open apps",
"admin.deleteProfile": "Delete profile",
"admin.deleteProfileConfirm": "Delete the profile “{name}” for good? Its highscores stay.",
"admin.deviceLoggedOut": "Device of {name} logged out",
"admin.devices": "Logged-in devices",
"admin.devicesHint": "Devices are logged out automatically after 12 hours without use.",
"admin.general": "General",
"admin.inactive": "inactive",
"admin.logoutDevice": "Log out device",
"admin.off": "Off",
"admin.on": "On",
"admin.permissions": "Permissions",
"admin.permissionsHint": "Admins may always do everything.",
"admin.perms.AddToQueue": "Add songs to the queue",
"admin.perms.CreateProfiles": "Create profiles",
"admin.perms.DeletePlaylists": "Delete playlists",
"admin.perms.EditAllProfiles": "Edit all profiles",
"admin.perms.EditOwnProfile": "Edit own profile",
"admin.perms.EditPlaylists": "Create and edit playlists",
"admin.perms.ManageQueue": "Manage queue (remove others’ entries, reorder)",
"admin.perms.StartSongs": "Start songs in the game",
"admin.perms.UseKeyboard": "Use the remote",
"admin.port": "Port",
"admin.profileDeleted": "Profile {name} deleted",
"admin.profiles": "Profiles",
"admin.profilesHint": "Inactive profiles are hidden in the app and in the game and cannot log in.",
"admin.queuePlaylistName": "Name of the queue playlist",
"admin.server": "Server",
"admin.serverHint": "The port is set in config.xml, HTTPS in the game under Options → Server.",
"admin.thisDevice": "This device",
"admin.title": "Settings",
"admin.version": "Version",
"audioModes.Instrumental": "Instrumental",
"audioModes.Karaoke": "Karaoke",
"audioModes.Normal": "Normal",
"audioModes.Vocals": "Instrumental/Vocals",
"common.back": "Back",
"common.cancel": "Cancel",
"common.close": "Close",
"common.loading": "Loading…",
"common.retry": "Try again",
"common.save": "Save",
"common.saved": "Saved",
"errors.busy": "Vocaluxe is busy, please try again",
"errors.forbidden": "You are not allowed to do this",
"errors.offline": "No connection to Vocaluxe",
"errors.startup": "Vocaluxe cannot be reached. Is the game running with the server switched on?",
"gameModes.Duet": "Duet",
"gameModes.Medley": "Medley",
"gameModes.Normal": "Normal",
"gameModes.Short": "Short version",
"help.connectText": "Vocaluxe shows the QR code to connect under Options → Server. If the server is switched off there, the app cannot be reached.",
"help.connectTitle": "Connecting",
"help.gameText": "In the game you open the queue in the song menu: press the space bar (or click the options button) and choose the playlist “{name}” under “Open playlist”. There you see the next song and all entries and can start them right away. With singers chosen and microphones set up, singing starts without the player selection; “Change players” shows the player selection.",
"help.gameTitle": "The queue in the game",
"help.httpsText": "If HTTPS is switched on, Vocaluxe uses a certificate it created itself. Browsers do not know it and show a warning such as “Your connection is not private” the first time. That is expected at home: tap “Advanced” and then “Proceed” (Safari: “Show details” → “visit this website”). The connection is still encrypted. HTTPS is only needed for features like the camera; otherwise HTTP is fine.",
"help.httpsTitle": "Certificate warning",
"help.playlistsText": "Playlists are collections for later, e.g. “Party classics”. You can create and fill them in the app, play them in the game or add all or some of their songs to the queue with one tap.",
"help.playlistsTitle": "Playlists",
"help.previewText": "The play button in the song list plays 30 seconds of a song on your phone, starting where the song menu of the game starts its preview.",
"help.previewTitle": "Preview",
"help.queueText": "Songs you add with your phone go to the queue. Everybody gets one song per round: if someone adds several songs, the songs of the others come in between. When adding a song you can choose game mode, audio and singers and change them later in the queue.",
"help.queueTitle": "The queue",
"help.startText": "With the permission to do so you can tap an entry in the queue and choose “Sing now”. The song starts right away in the game, unless someone is singing.",
"help.startTitle": "Start from the app",
"help.title": "Help",
"language.name": "English",
"live.reconnecting": "Connection lost – reconnecting …",
"login.create": "Create and start",
"login.enter": "Log in",
"login.locked": "Too many attempts, please wait a moment",
"login.name": "Name",
"login.newProfile": "New profile",
"login.noProfiles": "There are no profiles yet.",
"login.pin": "PIN",
"login.pinOptional": "PIN (optional, at least 4 characters)",
"login.pinTitle": "PIN for {name}",
"login.subtitle": "Pick your profile",
"login.title": "Who are you?",
"login.wrongPin": "Wrong PIN",
"more.admin": "Settings (admin)",
"more.api": "API documentation",
"more.help": "Help",
"more.language": "Language",
"more.loggedInAs": "Logged in as {name}",
"more.logout": "Log out",
"more.playlists": "Playlists",
"more.remote": "Remote",
"more.title": "More",
"nav.more": "More",
"nav.now": "Now",
"nav.players": "Players",
"nav.queue": "Queue",
"nav.queueShort": "Queue",
"nav.remote": "Remote",
"nav.remoteShort": "Remote",
"nav.songs": "Songs",
"now.noSong": "No song loaded",
"now.remote": "Remote",
"now.screen": "Screen",
"now.song": "Song",
"now.title": "Now",
"now.upNext": "Up next",
"players.allProfiles": "All profiles",
"players.changeAvatar": "Change picture",
"players.chooseAvatar": "Choose picture",
"players.difficulty": "Difficulty",
"players.difficulty0": "Easy",
"players.difficulty1": "Normal",
"players.difficulty2": "Hard",
"players.gameAvatars": "Vocaluxe avatars",
"players.myProfile": "My profile",
"players.name": "Name",
"players.newPin": "New PIN",
"players.ownPhoto": "Own photo",
"players.pin": "PIN",
"players.pinHint": "With a PIN nobody else can log in as you.",
"players.pinNone": "No PIN",
"players.pinSet": "PIN is set",
"players.pinTooShort": "The PIN needs at least 4 characters",
"players.removePin": "Remove PIN",
"players.role": "Role",
"players.roleChanged": "Role changed",
"players.roles.Admin": "Admin",
"players.roles.Guest": "Guest",
"players.roles.Player": "Player",
"players.title": "Players",
"playlists.addAllToQueue": "Add all to queue",
"playlists.addHint": "Add songs via “Songs” → tap a song → “Add to playlist”.",
"playlists.addToQueue": "Add to queue",
"playlists.addedAll": "Added {n} songs to the queue",
"playlists.create": "Create",
"playlists.delete": "Delete",
"playlists.deleteConfirm": "Really delete playlist “{name}”?",
"playlists.empty": "No playlists yet",
"playlists.emptyPlaylist": "This playlist is empty",
"playlists.intro": "Playlists are collections of songs for later. You also find them in the game in the song menu under “Open playlist”. With “Add to queue” their songs get their turn.",
"playlists.name": "Name",
"playlists.new": "New playlist",
"playlists.rename": "Rename",
"playlists.songs": "No songs | 1 song | {n} songs",
"playlists.title": "Playlists",
"queue.cannotStart": "Vocaluxe cannot start a song right now (for example while someone is singing).",
"queue.clear": "Clear queue",
"queue.clearConfirm": "Really remove all songs from the queue?",
"queue.cleared": "Queue cleared",
"queue.edit": "Edit entry",
"queue.empty": "The queue is empty",
"queue.emptyHint": "Pick something under “Songs”.",
"queue.fairHint": "Everybody gets one song per round.",
"queue.moveDown": "Move down",
"queue.moveUp": "Move up",
"queue.notifyAdded": "{name} added “{title}”",
"queue.notifyAddedMany": "{name} added {count} songs",
"queue.remove": "Remove",
"queue.removed": "Removed",
"queue.requestedBy": "added by {name}",
"queue.singers": "Singers: {names}",
"queue.startNow": "Sing now",
"queue.started": "Starting “{title}”",
"queue.title": "Queue",
"queue.unknown": "added in the game",
"queue.you": "You",
"queue.yourWish": "Your entry",
"remote.keys.Backspace": "Delete",
"remote.keys.Enter": "OK",
"remote.keys.Escape": "Back",
"remote.keys.PageDown": "Page ↓",
"remote.keys.PageUp": "Page ↑",
"remote.keys.Space": "Space",
"remote.keys.Tab": "Tab",
"remote.send": "Send",
"remote.text": "Type text",
"remote.title": "Remote",
"screens.Credits": "Credits",
"screens.Highscore": "Highscores",
"screens.Jukebox": "Jukebox",
"screens.Load": "Loading",
"screens.Main": "Main menu",
"screens.Names": "Player selection",
"screens.Party": "Party",
"screens.Profiles": "Profiles",
"screens.Score": "Score",
"screens.Sing": "Singing",
"screens.Song": "Song selection",
"screens.Statistics": "Statistics",
"songs.addToPlaylist": "Add to playlist",
"songs.addToQueue": "Add to queue",
"songs.addedToPlaylist": "Added to “{name}”",
"songs.addedToQueue": "“{title}” is in the queue",
"songs.allDecades": "All decades",
"songs.allGenres": "All genres",
"songs.allLanguages": "All languages",
"songs.audioMode": "Audio",
"songs.clearFilters": "Reset filters",
"songs.count": "No songs | 1 song | {n} songs",
"songs.decade": "{decade}s",
"songs.decadeLabel": "Decade",
"songs.duet": "Duet",
"songs.empty": "No songs found",
"songs.filter": "Filter",
"songs.filterLabel": "Filter",
"songs.gameMode": "Game mode",
"songs.genre": "Genre",
"songs.language": "Language",
"songs.noPlaylists": "No playlists yet",
"songs.onlyDuets": "Duets only",
"songs.players": "Who sings?",
"songs.playersHint": "Optional. Without a choice, players are picked in the game.",
"songs.playersMax": "At most {n} players",
"songs.preview": "Preview",
"songs.previewUnavailable": "Preview is not available for this song",
"songs.resetFilters": "Reset filters",
"songs.search": "Search artist or title",
"songs.showResults": "No songs | Show 1 song | Show {n} songs",
"songs.sort": "Sort",
"songs.sortBy.Artist": "Artist",
"songs.sortBy.Title": "Title",
"songs.sortBy.Year": "Year",
"songs.sortLabel": "Sort by",
"songs.stopPreview": "Stop preview",
"songs.title": "Songs"
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'
import { router } from './router'
import './styles.css'
createApp(App).use(i18n).use(router).mount('#app')
+51
View File
@@ -0,0 +1,51 @@
import { watch } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { session } from '@/stores/session'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
{
path: '/',
component: AppShell,
children: [
{ path: '', redirect: '/now' },
{ path: 'now', name: 'now', component: () => import('@/views/NowView.vue') },
{ path: 'songs', name: 'songs', component: () => import('@/views/SongsView.vue') },
{ path: 'queue', name: 'queue', component: () => import('@/views/QueueView.vue') },
{ path: 'players', name: 'players', component: () => import('@/views/PlayersView.vue') },
{ path: 'more', name: 'more', component: () => import('@/views/MoreView.vue') },
{ path: 'playlists', name: 'playlists', component: () => import('@/views/PlaylistsView.vue') },
{ path: 'playlists/:id(\\d+)', name: 'playlist', component: () => import('@/views/PlaylistView.vue'), props: (r) => ({ id: Number(r.params.id) }) },
{ path: 'help', name: 'help', component: () => import('@/views/HelpView.vue') },
{ path: 'remote', name: 'remote', component: () => import('@/views/RemoteView.vue') },
{ path: 'admin', name: 'admin', component: () => import('@/views/AdminView.vue'), meta: { admin: true } }
]
},
{ path: '/:pathMatch(.*)*', redirect: '/now' }
]
})
let restored: Promise<boolean> | null = null
router.beforeEach(async (to) => {
restored ??= session.restore().catch(() => false)
const loggedIn = (await restored) && session.state.session !== null
if (to.meta.public) return loggedIn && to.name === 'login' ? { name: 'now' } : true
if (!session.state.session) return { name: 'login', query: to.fullPath !== '/now' ? { next: to.fullPath } : {} }
if (to.meta.admin && !session.isAdmin.value) return { name: 'more' }
return true
})
// The session can end at any time (role changed, Vocaluxe restarted): go back to the login page.
watch(
() => session.state.session,
(value, old) => {
if (old && !value) {
restored = Promise.resolve(false)
router.replace({ name: 'login' })
}
}
)
+68
View File
@@ -0,0 +1,68 @@
// Live data from Vocaluxe (game state, queue, notifications), pushed by the server as Server-Sent Events.
// The browser reconnects by itself when the connection drops.
import { reactive } from 'vue'
import { api, ApiError, eventsUrl, type Notification, type QueueEntry, type State } from '@/api/client'
import { i18n } from '@/i18n'
import { session } from './session'
import { toast } from './toast'
export const live = reactive({
connected: false,
/** True once the first connection attempt finished, so the UI does not flash "offline" on start. */
started: false,
state: null as State | null,
queue: null as QueueEntry[] | null
})
let source: EventSource | null = null
let retryTimer: number | undefined
function parse<T>(event: Event): T {
return JSON.parse((event as MessageEvent<string>).data) as T
}
function onNotification(notification: Notification) {
if (notification.profile.id === session.profile.value?.id) return
const t = i18n.global.t
if (notification.type === 'queueAddedMany') toast(t('queue.notifyAddedMany', { name: notification.profile.name, count: notification.count }))
else if (notification.song) toast(t('queue.notifyAdded', { name: notification.profile.name, title: notification.song.title }))
}
export function connectLive() {
if (source) return
window.clearTimeout(retryTimer)
source = new EventSource(eventsUrl)
source.addEventListener('open', () => {
live.connected = true
live.started = true
})
source.addEventListener('state', (e) => (live.state = parse<State>(e)))
source.addEventListener('queue', (e) => (live.queue = parse<QueueEntry[]>(e)))
source.addEventListener('notification', (e) => onNotification(parse<Notification>(e)))
source.addEventListener('error', () => {
live.connected = false
live.started = true
if (source?.readyState !== EventSource.CLOSED) return
// The server refused the connection (e.g. logged out) or is gone: check the session, then try again.
source = null
api
.session()
.then(() => (retryTimer = window.setTimeout(connectLive, 3000)))
.catch((e) => {
if (e instanceof ApiError && e.status === 401) session.expire()
else retryTimer = window.setTimeout(connectLive, 3000)
})
})
}
export function disconnectLive() {
window.clearTimeout(retryTimer)
source?.close()
source = null
live.connected = false
}
/** Use the queue returned by an API call right away, without waiting for the event. */
export function setQueue(queue: QueueEntry[]) {
live.queue = queue
}
+55
View File
@@ -0,0 +1,55 @@
// Audio preview of songs. Only one song plays at a time; it starts where the song menu of the game starts its preview.
import { reactive } from 'vue'
import { audioUrl, type Song } from '@/api/client'
import { i18n } from '@/i18n'
import { toast } from './toast'
const previewLength = 30
export const preview = reactive({
songId: null as number | null,
loading: false
})
let audio: HTMLAudioElement | null = null
let stopTimer: number | undefined
export function stopPreview() {
window.clearTimeout(stopTimer)
audio?.pause()
preview.songId = null
preview.loading = false
}
export function togglePreview(song: Song) {
if (preview.songId === song.id) {
stopPreview()
return
}
stopPreview()
audio ??= new Audio()
const player = audio
preview.songId = song.id
preview.loading = true
player.src = audioUrl(song.id)
player.preload = 'auto'
player.onloadedmetadata = () => {
const length = Number.isFinite(player.duration) ? player.duration : 0
let start = song.previewStart ?? length / 4
if (length > 0 && start > length - 5) start = Math.max(0, Math.min(length / 4, length - 5))
player.currentTime = Math.max(0, start)
}
player.onplaying = () => {
preview.loading = false
window.clearTimeout(stopTimer)
stopTimer = window.setTimeout(stopPreview, previewLength * 1000)
}
player.onended = stopPreview
player.onerror = () => {
if (preview.songId === song.id) {
stopPreview()
toast(i18n.global.t('songs.previewUnavailable'), 'error')
}
}
player.play().catch(() => stopPreview())
}
+68
View File
@@ -0,0 +1,68 @@
import { computed, reactive } from 'vue'
import { api, ApiError, type Permission, type Profile, type ServerInfo, type Session } from '@/api/client'
import { storage } from './storage'
const lastProfileKey = 'vlx.lastProfile'
const state = reactive({
server: null as ServerInfo | null,
session: null as Session | null,
/** Changes when the own avatar changes, so images reload. */
avatarVersion: ''
})
export const session = {
state,
profile: computed(() => state.session?.profile ?? null),
isAdmin: computed(() => state.session?.profile.role === 'Admin'),
can: (permission: Permission) => state.session?.permissions.includes(permission) ?? false,
/** Restores the session after a reload. After a restart of Vocaluxe profiles without PIN are logged in again silently. */
async restore(): Promise<boolean> {
state.server ??= await api.server()
try {
state.session = await api.session()
return true
} catch (e) {
if (!(e instanceof ApiError) || e.status !== 401) throw e
}
const last = storage.get(lastProfileKey)
if (!last) return false
const profile = (await api.profiles()).find((p) => p.id === last)
if (!profile || profile.hasPin) return false
try {
await this.login(profile)
return true
} catch {
return false
}
},
async login(profile: Profile, pin?: string) {
state.session = await api.login(profile.id, pin)
storage.set(lastProfileKey, profile.id)
},
async logout() {
await api.logout().catch(() => undefined)
storage.remove(lastProfileKey)
state.session = null
},
/** Called when the API says 401: the session expired or the role changed. */
expire() {
state.session = null
},
updateProfile(profile: Profile, avatarChanged = false) {
if (state.session && state.session.profile.id === profile.id) {
state.session.profile = profile
if (avatarChanged) state.avatarVersion = String(Date.now())
}
},
async refresh() {
state.session = await api.session()
}
}
+24
View File
@@ -0,0 +1,24 @@
// localStorage can be unavailable (private mode, blocked site data); the app must work without it.
export const storage = {
get(key: string): string | null {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
},
set(key: string, value: string) {
try {
window.localStorage.setItem(key, value)
} catch {
/* ignore */
}
},
remove(key: string) {
try {
window.localStorage.removeItem(key)
} catch {
/* ignore */
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { reactive } from 'vue'
import { ApiError } from '@/api/client'
import { i18n } from '@/i18n'
import { session } from './session'
export interface Toast {
id: number
text: string
kind: 'info' | 'error'
}
let nextId = 1
export const toasts = reactive<Toast[]>([])
export function toast(text: string, kind: Toast['kind'] = 'info') {
const entry = { id: nextId++, text, kind }
toasts.push(entry)
window.setTimeout(() => {
const index = toasts.findIndex((t) => t.id === entry.id)
if (index >= 0) toasts.splice(index, 1)
}, 3500)
}
/** Shows an API error in a friendly way. Returns nothing so it can be used as `.catch(showError)`. */
export function showError(error: unknown) {
const t = i18n.global.t
if (error instanceof ApiError) {
if (error.status === 401) {
session.expire()
return
}
if (error.status === 0) return toast(t('errors.offline'), 'error')
if (error.status === 403) return toast(t('errors.forbidden'), 'error')
if (error.status === 503) return toast(t('errors.busy'), 'error')
return toast(error.message, 'error')
}
toast(String(error), 'error')
}
+331
View File
@@ -0,0 +1,331 @@
:root {
color-scheme: dark;
--bg: #14121c;
--surface: #1f1c2b;
--surface-raised: #2a2639;
--surface-hover: #332e45;
--border: #3a3550;
--text: #f2f0f7;
--text-muted: #a7a2b8;
--accent: #ff4fa3;
--accent-soft: rgba(255, 79, 163, 0.16);
--ok: #3ddc97;
--error: #e5484d;
--radius: 14px;
--radius-small: 10px;
--gutter: 16px;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
--tabbar-height: 64px;
--nowbar-height: 56px;
--sidebar-width: 220px;
--panel-width: 340px;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #f6f5fa;
--surface: #ffffff;
--surface-raised: #eeecf5;
--surface-hover: #e4e1ee;
--border: #d9d5e6;
--text: #1b1825;
--text-muted: #5f5a70;
--accent: #d61f7c;
--accent-soft: rgba(214, 31, 124, 0.1);
--ok: #138a5a;
--error: #c62828;
--shadow: 0 8px 24px rgba(30, 20, 60, 0.12);
}
}
@media (min-width: 768px) {
:root {
--tabbar-height: 0px;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
h1,
h2,
h3 {
margin: 0;
line-height: 1.25;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.1rem;
}
a {
color: inherit;
}
img {
display: block;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: var(--surface-raised);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.btn--primary {
background: var(--accent);
color: #fff;
}
.btn--primary:hover:not(:disabled) {
background: var(--accent);
filter: brightness(1.08);
}
.btn--danger {
color: var(--error);
}
.btn--block {
width: 100%;
}
.icon-btn {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
flex: none;
border: 0;
border-radius: 50%;
background: transparent;
}
.icon-btn:hover:not(:disabled) {
background: var(--surface-hover);
}
/* Forms */
.field {
display: grid;
gap: 6px;
}
.field > span {
font-size: 0.875rem;
color: var(--text-muted);
}
.input,
.select {
width: 100%;
min-height: 44px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--surface);
color: var(--text);
font: inherit;
}
.select {
appearance: none;
padding-right: 36px;
background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
background-position:
calc(100% - 18px) 50%,
calc(100% - 13px) 50%;
background-size: 5px 5px;
background-repeat: no-repeat;
}
.check {
display: flex;
align-items: flex-start;
gap: 12px;
min-height: 44px;
padding: 10px 0;
cursor: pointer;
}
.check input {
width: 20px;
height: 20px;
margin: 2px 0 0;
accent-color: var(--accent);
flex: none;
}
/* Layout helpers */
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--gutter);
padding: var(--gutter);
max-width: 900px;
width: 100%;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
}
.card {
background: var(--surface);
border-radius: var(--radius);
padding: var(--gutter);
}
.muted {
color: var(--text-muted);
}
.list {
display: grid;
margin: 0;
padding: 0;
list-style: none;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: 12px;
min-height: 64px;
padding: 8px 8px 8px 12px;
border-bottom: 1px solid var(--border);
text-decoration: none;
color: inherit;
background: none;
border-left: 0;
border-right: 0;
border-top: 0;
width: 100%;
text-align: left;
}
.list > :last-child > .row,
.list > .row:last-child {
border-bottom: 0;
}
button.row:hover,
a.row:hover {
background: var(--surface-hover);
}
.row-text {
flex: 1;
min-width: 0;
display: grid;
}
.row-title,
.row-sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title {
font-weight: 600;
}
.row-sub {
font-size: 0.875rem;
color: var(--text-muted);
}
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
vertical-align: middle;
}
.badge--accent {
background: var(--accent-soft);
color: var(--accent);
}
.empty {
display: grid;
justify-items: center;
gap: 8px;
padding: 48px 16px;
text-align: center;
color: var(--text-muted);
}
.visually-hidden {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
+317
View File
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type AdminSession, type AdminSettings, type AdminStatus, type Permission, type Profile } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import ProfileAvatar from '@/components/ProfileAvatar.vue'
import { session } from '@/stores/session'
import { showError, toast } from '@/stores/toast'
const { t, locale } = useI18n()
const permissions: Permission[] = ['AddToQueue', 'ManageQueue', 'UseKeyboard', 'EditPlaylists', 'DeletePlaylists', 'EditOwnProfile', 'EditAllProfiles', 'CreateProfiles', 'StartSongs']
const settings = ref<AdminSettings | null>(null)
const status = ref<AdminStatus | null>(null)
const sessions = ref<AdminSession[]>([])
const profiles = ref<Profile[]>([])
const busy = ref(false)
const me = computed(() => session.profile.value)
const time = (value: string) => new Date(value).toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' })
onMounted(async () => {
settings.value = await api.adminSettings().catch((e) => {
showError(e)
return null
})
await Promise.all([loadStatus(), loadSessions(), loadProfiles()])
})
async function loadStatus() {
status.value = await api.adminStatus().catch(() => null)
}
async function loadSessions() {
sessions.value = await api.adminSessions().catch(() => [])
}
async function loadProfiles() {
profiles.value = await api.adminProfiles().catch(() => [])
}
function toggle(list: Permission[], permission: Permission, on: boolean) {
const index = list.indexOf(permission)
if (on && index < 0) list.push(permission)
if (!on && index >= 0) list.splice(index, 1)
}
async function save() {
if (!settings.value) return
busy.value = true
try {
settings.value = await api.saveAdminSettings(settings.value)
toast(t('common.saved'))
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
async function logoutDevice(s: AdminSession) {
try {
await api.deleteSession(s.id)
toast(t('admin.deviceLoggedOut', { name: s.profile.name }))
} catch (e) {
showError(e)
}
await loadSessions()
}
async function setActive(p: Profile, active: boolean) {
try {
await api.setProfileActive(p.id, active)
} catch (e) {
showError(e)
}
await Promise.all([loadProfiles(), loadSessions()])
}
async function deleteProfile(p: Profile) {
if (!window.confirm(t('admin.deleteProfileConfirm', { name: p.name }))) return
try {
await api.deleteProfile(p.id)
toast(t('admin.profileDeleted', { name: p.name }))
} catch (e) {
showError(e)
}
await Promise.all([loadProfiles(), loadSessions()])
}
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('admin.title') }}</h1>
</header>
<section v-if="status" class="card">
<h2>{{ t('admin.server') }}</h2>
<dl class="facts">
<dt>{{ t('admin.address') }}</dt>
<dd>{{ status.address.replace(/\/$/, '') }}</dd>
<dt>{{ t('admin.port') }}</dt>
<dd>{{ status.port }}</dd>
<dt>HTTPS</dt>
<dd>
<span :class="['badge', { 'badge--accent': status.https }]">{{ status.https ? t('admin.on') : t('admin.off') }}</span>
</dd>
<dt>{{ t('admin.connected') }}</dt>
<dd>{{ status.connectedClients }}</dd>
<dt>{{ t('admin.version') }}</dt>
<dd>{{ status.version }}</dd>
</dl>
<p class="muted">{{ t('admin.serverHint') }}</p>
</section>
<p v-if="!settings" class="muted">{{ t('common.loading') }}</p>
<form v-else class="form" @submit.prevent="save">
<section class="card">
<h2>{{ t('admin.general') }}</h2>
<label class="check">
<input v-model="settings.allowProfileCreation" type="checkbox" />
<span>{{ t('admin.allowProfileCreation') }}</span>
</label>
<label class="field">
<span>{{ t('admin.queuePlaylistName') }}</span>
<input v-model="settings.queuePlaylistName" class="input" maxlength="64" required />
</label>
</section>
<section class="card">
<h2>{{ t('admin.permissions') }}</h2>
<p class="muted">{{ t('admin.permissionsHint') }}</p>
<table class="matrix">
<colgroup>
<col class="perm" />
<col class="role" />
<col class="role" />
<col class="role" />
</colgroup>
<thead>
<tr>
<th></th>
<th>{{ t('players.roles.Guest') }}</th>
<th>{{ t('players.roles.Player') }}</th>
<th>{{ t('players.roles.Admin') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="p in permissions" :key="p">
<th scope="row">{{ t(`admin.perms.${p}`) }}</th>
<td>
<input
type="checkbox"
:checked="settings.guestPermissions.includes(p)"
:aria-label="`${t('players.roles.Guest')}: ${t(`admin.perms.${p}`)}`"
@change="toggle(settings.guestPermissions, p, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td>
<input
type="checkbox"
:checked="settings.playerPermissions.includes(p)"
:aria-label="`${t('players.roles.Player')}: ${t(`admin.perms.${p}`)}`"
@change="toggle(settings.playerPermissions, p, ($event.target as HTMLInputElement).checked)"
/>
</td>
<td><input type="checkbox" checked disabled :aria-label="`${t('players.roles.Admin')}: ${t(`admin.perms.${p}`)}`" /></td>
</tr>
</tbody>
</table>
</section>
<button class="btn btn--primary" :disabled="busy">{{ t('common.save') }}</button>
</form>
<section class="group">
<h2>{{ t('admin.devices') }}</h2>
<p class="muted">{{ t('admin.devicesHint') }}</p>
<ul class="list">
<li v-for="s in sessions" :key="s.id" class="row">
<span class="row-text">
<span class="row-title">
{{ s.profile.name }} <span v-if="s.isCurrent" class="badge badge--accent">{{ t('admin.thisDevice') }}</span>
</span>
<span class="row-sub">{{ [s.device, s.remoteAddress, time(s.lastSeen)].filter(Boolean).join(' · ') }}</span>
</span>
<button v-if="!s.isCurrent" type="button" class="icon-btn" :aria-label="t('admin.logoutDevice')" :title="t('admin.logoutDevice')" @click="logoutDevice(s)">
<AppIcon name="logout" :size="20" />
</button>
</li>
</ul>
</section>
<section class="group">
<h2>{{ t('admin.profiles') }}</h2>
<p class="muted">{{ t('admin.profilesHint') }}</p>
<ul class="list">
<li v-for="p in profiles" :key="p.id" :class="['row', { inactive: !p.isActive }]">
<ProfileAvatar :profile="p" :size="40" />
<span class="row-text">
<span class="row-title">{{ p.name }}</span>
<span class="row-sub">{{ t(`players.roles.${p.role}`) }}{{ p.isActive ? '' : ' · ' + t('admin.inactive') }}</span>
</span>
<label class="switch" :title="t('admin.active')">
<input
type="checkbox"
:checked="p.isActive"
:disabled="p.id === me?.id"
:aria-label="`${t('admin.active')}: ${p.name}`"
@change="setActive(p, ($event.target as HTMLInputElement).checked)"
/>
</label>
<button
type="button"
class="icon-btn btn--danger"
:disabled="p.id === me?.id"
:aria-label="`${t('admin.deleteProfile')}: ${p.name}`"
:title="t('admin.deleteProfile')"
@click="deleteProfile(p)"
>
<AppIcon name="trash" :size="20" />
</button>
</li>
</ul>
</section>
</div>
</template>
<style scoped>
.form,
.card,
.group {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.group h2,
.group p {
margin: 0;
}
.facts {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 16px;
margin: 0;
}
.facts dt {
color: var(--text-muted);
}
.facts dd {
margin: 0;
overflow-wrap: anywhere;
}
.inactive .row-title {
color: var(--text-muted);
}
.switch input {
width: 22px;
height: 22px;
accent-color: var(--accent);
}
.icon-btn:disabled {
opacity: 0.35;
}
.card p {
margin: 0;
}
.matrix {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.matrix col.perm {
width: auto;
}
.matrix col.role {
width: 18%;
}
.matrix th,
.matrix td {
padding: 10px 6px;
border-bottom: 1px solid var(--border);
text-align: center;
}
.matrix tbody th {
text-align: left;
font-weight: 400;
overflow-wrap: anywhere;
hyphens: auto;
}
.matrix thead th {
padding: 10px 4px;
font-size: 0.8125rem;
color: var(--text-muted);
overflow-wrap: anywhere;
hyphens: auto;
}
.matrix input {
width: 22px;
height: 22px;
accent-color: var(--accent);
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AppIcon, { type IconName } from '@/components/AppIcon.vue'
import { session } from '@/stores/session'
const { t } = useI18n()
const queueName = computed(() => session.state.server?.queuePlaylistName ?? 'WebApp Queue')
const sections: { icon: IconName; key: string }[] = [
{ icon: 'queue', key: 'queue' },
{ icon: 'music', key: 'game' },
{ icon: 'mic', key: 'start' },
{ icon: 'playlist', key: 'playlists' },
{ icon: 'play', key: 'preview' },
{ icon: 'globe', key: 'connect' },
{ icon: 'lock', key: 'https' }
]
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('help.title') }}</h1>
</header>
<section v-for="s in sections" :key="s.key" class="card section">
<h2><AppIcon :name="s.icon" :size="20" /> {{ t(`help.${s.key}Title`) }}</h2>
<p>{{ t(`help.${s.key}Text`, { name: queueName }) }}</p>
</section>
</div>
</template>
<style scoped>
.section {
display: grid;
gap: 8px;
}
.section h2 {
display: flex;
align-items: center;
gap: 10px;
}
.section p {
margin: 0;
color: var(--text-muted);
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { api, ApiError, type Profile } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import AppSheet from '@/components/AppSheet.vue'
import ProfileAvatar from '@/components/ProfileAvatar.vue'
import { session } from '@/stores/session'
import { showError } from '@/stores/toast'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const profiles = ref<Profile[] | null>(null)
const unreachable = ref(false)
const pinFor = ref<Profile | null>(null)
const pin = ref('')
const pinError = ref('')
const creating = ref(false)
const newName = ref('')
const newPin = ref('')
const busy = ref(false)
const server = computed(() => session.state.server)
async function load() {
unreachable.value = false
try {
session.state.server = await api.server()
profiles.value = await api.profiles()
} catch {
unreachable.value = true
}
}
onMounted(load)
function done() {
const next = typeof route.query.next === 'string' && route.query.next.startsWith('/') ? route.query.next : '/now'
router.replace(next)
}
async function pick(profile: Profile) {
if (profile.hasPin) {
pinFor.value = profile
pin.value = ''
pinError.value = ''
return
}
await login(profile)
}
async function login(profile: Profile, withPin?: string) {
busy.value = true
try {
await session.login(profile, withPin)
pinFor.value = null
done()
} catch (e) {
if (e instanceof ApiError && (e.status === 403 || e.status === 429) && pinFor.value) {
pinError.value = e.status === 429 ? t('login.locked') : t('login.wrongPin')
pin.value = ''
} else {
showError(e)
}
} finally {
busy.value = false
}
}
async function create() {
if (!newName.value.trim()) return
busy.value = true
try {
const pinValue = newPin.value || undefined
const profile = await api.createProfile(newName.value.trim(), pinValue)
await session.login(profile, pinValue)
creating.value = false
done()
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
</script>
<template>
<main class="login">
<header class="login-header">
<img src="/icon.png" alt="" width="56" height="56" />
<h1>{{ t('login.title') }}</h1>
<p class="muted">{{ t('login.subtitle') }}</p>
</header>
<div v-if="unreachable" class="card notice">
<p>{{ t('errors.startup') }}</p>
<button class="btn btn--primary" @click="load">{{ t('common.retry') }}</button>
</div>
<p v-else-if="profiles === null" class="muted center">{{ t('common.loading') }}</p>
<template v-else>
<p v-if="profiles.length === 0 && !server?.allowProfileCreation" class="muted center">{{ t('login.noProfiles') }}</p>
<ul class="tiles">
<li v-for="p in profiles" :key="p.id">
<button class="tile" :disabled="busy" @click="pick(p)">
<ProfileAvatar :profile="p" :size="88" />
<span class="tile-name">{{ p.name }}</span>
<AppIcon v-if="p.hasPin" name="lock" :size="16" class="tile-lock" />
</button>
</li>
<li v-if="server?.allowProfileCreation">
<button class="tile tile--new" :disabled="busy" @click="creating = true">
<span class="tile-plus"><AppIcon name="plus" :size="36" /></span>
<span class="tile-name">{{ t('login.newProfile') }}</span>
</button>
</li>
</ul>
</template>
<AppSheet :open="pinFor !== null" :title="pinFor ? t('login.pinTitle', { name: pinFor.name }) : ''" @close="pinFor = null">
<form class="form" @submit.prevent="pinFor && login(pinFor, pin)">
<label class="field">
<span>{{ t('login.pin') }}</span>
<input v-model="pin" class="input" type="password" inputmode="numeric" autocomplete="current-password" autofocus />
</label>
<p v-if="pinError" class="error">{{ pinError }}</p>
<button class="btn btn--primary btn--block" :disabled="busy || !pin">{{ t('login.enter') }}</button>
</form>
</AppSheet>
<AppSheet :open="creating" :title="t('login.newProfile')" @close="creating = false">
<form class="form" @submit.prevent="create">
<label class="field">
<span>{{ t('login.name') }}</span>
<input v-model="newName" class="input" maxlength="32" autocomplete="nickname" required autofocus />
</label>
<label class="field">
<span>{{ t('login.pinOptional') }}</span>
<input v-model="newPin" class="input" type="password" inputmode="numeric" minlength="4" maxlength="32" autocomplete="new-password" />
</label>
<button class="btn btn--primary btn--block" :disabled="busy || !newName.trim() || (newPin.length > 0 && newPin.length < 4)">
{{ t('login.create') }}
</button>
</form>
</AppSheet>
</main>
</template>
<style scoped>
.login {
display: grid;
gap: 24px;
align-content: start;
max-width: 760px;
margin: 0 auto;
padding: calc(32px + env(safe-area-inset-top)) var(--gutter) 32px;
}
.login-header {
display: grid;
justify-items: center;
gap: 8px;
text-align: center;
}
.login-header img {
border-radius: 14px;
}
.login-header p {
margin: 0;
}
.center {
text-align: center;
}
.notice {
display: grid;
justify-items: center;
gap: 12px;
text-align: center;
}
.notice p {
margin: 0;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.tile {
position: relative;
display: grid;
justify-items: center;
gap: 10px;
width: 100%;
padding: 18px 8px 14px;
border: 0;
border-radius: var(--radius);
background: var(--surface);
}
.tile:hover:not(:disabled) {
background: var(--surface-hover);
}
.tile-name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.tile-lock {
position: absolute;
top: 10px;
right: 10px;
color: var(--text-muted);
}
.tile-plus {
display: grid;
place-items: center;
width: 88px;
height: 88px;
border-radius: 50%;
border: 2px dashed var(--border);
color: var(--text-muted);
}
.form {
display: grid;
gap: 14px;
}
.error {
margin: 0;
color: var(--error);
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AppIcon from '@/components/AppIcon.vue'
import ProfileAvatar from '@/components/ProfileAvatar.vue'
import { languages, setLanguage } from '@/i18n'
import { session } from '@/stores/session'
const { t, locale } = useI18n()
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('more.title') }}</h1>
</header>
<div v-if="session.profile.value" class="card me">
<ProfileAvatar :profile="session.profile.value" :size="48" />
<span class="row-text">
<span class="row-title">{{ t('more.loggedInAs', { name: session.profile.value.name }) }}</span>
<span class="row-sub">{{ t(`players.roles.${session.profile.value.role}`) }}</span>
</span>
</div>
<nav class="list">
<RouterLink to="/playlists" class="row"><AppIcon name="playlist" /><span class="row-text row-title">{{ t('more.playlists') }}</span><AppIcon name="right" class="muted" /></RouterLink>
<RouterLink v-if="session.isAdmin.value" to="/admin" class="row">
<AppIcon name="shield" /><span class="row-text row-title">{{ t('more.admin') }}</span><AppIcon name="right" class="muted" />
</RouterLink>
<RouterLink to="/help" class="row"><AppIcon name="help" /><span class="row-text row-title">{{ t('more.help') }}</span><AppIcon name="right" class="muted" /></RouterLink>
<label class="row">
<AppIcon name="globe" />
<span class="row-text row-title">{{ t('more.language') }}</span>
<select class="select lang" :value="locale" @change="setLanguage(($event.target as HTMLSelectElement).value)">
<option v-for="l in languages" :key="l.code" :value="l.code">{{ l.name }}</option>
</select>
</label>
<a href="/swagger" target="_blank" rel="noopener" class="row"><AppIcon name="code" /><span class="row-text row-title">{{ t('more.api') }}</span></a>
<button class="row danger" @click="session.logout()"><AppIcon name="logout" /><span class="row-text row-title">{{ t('more.logout') }}</span></button>
</nav>
<p class="muted version">{{ session.state.server?.name }} {{ session.state.server?.version }}</p>
</div>
</template>
<style scoped>
.me {
display: flex;
align-items: center;
gap: 14px;
}
.lang {
width: auto;
min-height: 40px;
}
.danger {
color: var(--error);
}
.version {
margin: 0;
text-align: center;
font-size: 0.875rem;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AppIcon from '@/components/AppIcon.vue'
import NowPanel from '@/components/NowPanel.vue'
import { session } from '@/stores/session'
const { t } = useI18n()
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('now.title') }}</h1>
<RouterLink v-if="session.can('UseKeyboard')" to="/remote" class="btn">
<AppIcon name="keyboard" /> {{ t('now.remote') }}
</RouterLink>
</header>
<NowPanel :up-next-count="10" />
</div>
</template>
+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, gameAvatarUrl, type Avatar, type Profile, type Role } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import AppSheet from '@/components/AppSheet.vue'
import ProfileAvatar from '@/components/ProfileAvatar.vue'
import { session } from '@/stores/session'
import { showError, toast } from '@/stores/toast'
const { t } = useI18n()
const me = computed(() => session.profile.value)
const canEdit = computed(() => session.can('EditOwnProfile') || session.can('EditAllProfiles'))
const roles: Role[] = ['Guest', 'Player', 'Admin']
const difficulties = computed(() => [t('players.difficulty0'), t('players.difficulty1'), t('players.difficulty2')])
const name = ref('')
const difficulty = ref(0)
const newPin = ref('')
const busy = ref(false)
const profiles = ref<Profile[]>([])
const fileInput = ref<HTMLInputElement>()
const choosing = ref(false)
const avatars = ref<Avatar[]>([])
watch(
me,
(profile) => {
name.value = profile?.name ?? ''
difficulty.value = profile?.difficulty ?? 0
},
{ immediate: true }
)
onMounted(loadProfiles)
async function loadProfiles() {
profiles.value = await api.profiles().catch((e) => {
showError(e)
return []
})
}
async function save() {
if (!me.value) return
if (newPin.value && newPin.value.length < 4) {
toast(t('players.pinTooShort'), 'error')
return
}
busy.value = true
try {
const profile = await api.updateProfile(me.value.id, {
name: name.value.trim() || undefined,
difficulty: difficulty.value,
pin: newPin.value || undefined
})
session.updateProfile(profile)
newPin.value = ''
toast(t('common.saved'))
loadProfiles()
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
async function removePin() {
if (!me.value) return
busy.value = true
try {
session.updateProfile(await api.updateProfile(me.value.id, { pin: '' }))
toast(t('common.saved'))
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
async function chooseAvatar() {
choosing.value = true
avatars.value = await api.avatars().catch(() => [])
}
async function selectGameAvatar(avatar: Avatar) {
if (!me.value) return
busy.value = true
try {
session.updateProfile(await api.selectGameAvatar(me.value.id, avatar.id), true)
choosing.value = false
loadProfiles()
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
async function uploadAvatar(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file || !me.value) return
choosing.value = false
busy.value = true
try {
session.updateProfile(await api.setAvatar(me.value.id, file), true)
loadProfiles()
} catch (e) {
showError(e)
} finally {
busy.value = false
if (fileInput.value) fileInput.value.value = ''
}
}
async function changeRole(profile: Profile, role: Role) {
try {
const updated = await api.setRole(profile.id, role)
profiles.value = profiles.value.map((p) => (p.id === updated.id ? updated : p))
toast(t('players.roleChanged'))
} catch (e) {
showError(e)
loadProfiles()
}
}
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('players.title') }}</h1>
</header>
<section v-if="me" class="card mine">
<h2>{{ t('players.myProfile') }}</h2>
<div class="avatar-row">
<ProfileAvatar :profile="me" :size="96" />
<div v-if="canEdit">
<input ref="fileInput" type="file" accept="image/*" class="visually-hidden" @change="uploadAvatar" />
<button class="btn" :disabled="busy" @click="chooseAvatar"><AppIcon name="camera" /> {{ t('players.changeAvatar') }}</button>
</div>
</div>
<form class="form" @submit.prevent="save">
<label class="field">
<span>{{ t('players.name') }}</span>
<input v-model="name" class="input" maxlength="32" :disabled="!canEdit" />
</label>
<label class="field">
<span>{{ t('players.difficulty') }}</span>
<select v-model.number="difficulty" class="select" :disabled="!canEdit">
<option v-for="(label, index) in difficulties" :key="index" :value="index">{{ label }}</option>
</select>
</label>
<div class="field">
<span>{{ t('players.pin') }} · {{ me.hasPin ? t('players.pinSet') : t('players.pinNone') }}</span>
<input
v-model="newPin"
class="input"
type="password"
inputmode="numeric"
autocomplete="new-password"
maxlength="32"
:placeholder="t('players.newPin')"
:disabled="!canEdit"
/>
<small class="muted">{{ t('players.pinHint') }}</small>
</div>
<div v-if="canEdit" class="buttons">
<button class="btn btn--primary" :disabled="busy">{{ t('common.save') }}</button>
<button v-if="me.hasPin" type="button" class="btn btn--danger" :disabled="busy" @click="removePin">{{ t('players.removePin') }}</button>
</div>
</form>
</section>
<section class="all">
<h2>{{ t('players.allProfiles') }}</h2>
<ul class="list">
<li v-for="p in profiles" :key="p.id" class="row">
<ProfileAvatar :profile="p" :size="40" />
<span class="row-text">
<span class="row-title">{{ p.name }} <AppIcon v-if="p.hasPin" name="lock" :size="14" class="muted" /></span>
<span v-if="!session.isAdmin.value" class="row-sub">{{ t(`players.roles.${p.role}`) }}</span>
</span>
<select
v-if="session.isAdmin.value"
class="select role"
:value="p.role"
:disabled="p.id === me?.id"
:aria-label="t('players.role')"
@change="changeRole(p, ($event.target as HTMLSelectElement).value as Role)"
>
<option v-for="r in roles" :key="r" :value="r">{{ t(`players.roles.${r}`) }}</option>
</select>
</li>
</ul>
</section>
<AppSheet :open="choosing" :title="t('players.chooseAvatar')" @close="choosing = false">
<button class="btn btn--block" :disabled="busy" @click="fileInput?.click()"><AppIcon name="camera" /> {{ t('players.ownPhoto') }}</button>
<template v-if="avatars.length">
<h3 class="avatars-title">{{ t('players.gameAvatars') }}</h3>
<div class="avatars">
<button v-for="a in avatars" :key="a.id" type="button" class="avatar-choice" :disabled="busy" @click="selectGameAvatar(a)">
<img :src="gameAvatarUrl(a.id, 160)" alt="" loading="lazy" />
</button>
</div>
</template>
</AppSheet>
</div>
</template>
<style scoped>
.mine,
.all {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.avatars-title {
margin: 16px 0 8px;
font-size: 0.875rem;
color: var(--text-muted);
}
.avatars {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px;
}
.avatar-choice {
aspect-ratio: 1;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
overflow: hidden;
background: #fff;
}
.avatar-choice:hover:not(:disabled),
.avatar-choice:focus-visible {
border-color: var(--accent);
}
.avatar-choice img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
}
.form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.role {
width: auto;
min-height: 40px;
}
@media (min-width: 1000px) {
.page {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
}
.page-header {
grid-column: 1 / -1;
}
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { api, type Playlist } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import AppSheet from '@/components/AppSheet.vue'
import SongCover from '@/components/SongCover.vue'
import { session } from '@/stores/session'
import { setQueue } from '@/stores/live'
import { showError, toast } from '@/stores/toast'
const props = defineProps<{ id: number }>()
const { t } = useI18n()
const router = useRouter()
const playlist = ref<Playlist | null>(null)
const busy = ref(false)
const renaming = ref(false)
const newName = ref('')
onMounted(async () => {
try {
playlist.value = await api.playlist(props.id)
} catch (e) {
showError(e)
router.replace({ name: 'playlists' })
}
})
async function run(action: () => Promise<Playlist>) {
busy.value = true
try {
playlist.value = await action()
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
async function addToQueue(positions?: number[]) {
busy.value = true
try {
setQueue(await api.importToQueue(props.id, positions))
toast(positions?.length === 1 && playlist.value ? t('songs.addedToQueue', { title: playlist.value.songs[positions[0]].song.title }) : t('playlists.addedAll', { n: positions?.length ?? playlist.value?.songs.length ?? 0 }))
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
const moveUp = (index: number) => run(() => api.moveInPlaylist(props.id, index, index - 1))
const moveDown = (index: number) => run(() => api.moveInPlaylist(props.id, index, index + 1))
const removeSong = (index: number) => run(() => api.removeFromPlaylist(props.id, index))
function startRename() {
newName.value = playlist.value?.name ?? ''
renaming.value = true
}
async function rename() {
if (!newName.value.trim()) return
await run(() => api.renamePlaylist(props.id, newName.value.trim()))
renaming.value = false
}
async function remove() {
if (!playlist.value || !window.confirm(t('playlists.deleteConfirm', { name: playlist.value.name }))) return
busy.value = true
try {
await api.deletePlaylist(props.id)
router.replace({ name: 'playlists' })
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
</script>
<template>
<div class="page">
<header class="page-header">
<RouterLink to="/playlists" class="icon-btn" :aria-label="t('common.back')"><AppIcon name="back" /></RouterLink>
<h1 class="title">{{ playlist?.name ?? '…' }}</h1>
<template v-if="playlist">
<button
v-if="session.can('EditPlaylists')"
class="icon-btn"
:aria-label="t('playlists.rename')"
@click="startRename"
>
<AppIcon name="edit" />
</button>
<button v-if="session.can('DeletePlaylists')" class="icon-btn" :aria-label="t('playlists.delete')" :disabled="busy" @click="remove">
<AppIcon name="trash" />
</button>
</template>
</header>
<template v-if="playlist">
<p class="muted intro">{{ t('playlists.intro') }}</p>
<button v-if="session.can('AddToQueue') && playlist.songs.length" class="btn btn--primary" :disabled="busy" @click="addToQueue()">
<AppIcon name="queue" :size="18" /> {{ t('playlists.addAllToQueue') }}
</button>
<div v-if="playlist.songs.length === 0" class="empty">
<AppIcon name="music" :size="40" />
<p>{{ t('playlists.emptyPlaylist') }}</p>
<p v-if="session.can('EditPlaylists')">{{ t('playlists.addHint') }}</p>
</div>
<ol v-else class="list songs">
<li v-for="(entry, index) in playlist.songs" :key="`${entry.song.id}-${index}`" class="row">
<SongCover :song-id="entry.song.id" :has-cover="entry.song.hasCover" />
<span class="row-text">
<span class="row-title">{{ entry.song.title }}</span>
<span class="row-sub">{{ entry.song.artist }}</span>
</span>
<button v-if="session.can('AddToQueue')" class="icon-btn" :disabled="busy" :aria-label="t('playlists.addToQueue')" @click="addToQueue([index])">
<AppIcon name="plus" />
</button>
<template v-if="session.can('EditPlaylists')">
<button class="icon-btn" :disabled="busy || index === 0" :aria-label="t('queue.moveUp')" @click="moveUp(index)">
<AppIcon name="chevronUp" />
</button>
<button
class="icon-btn"
:disabled="busy || index === playlist.songs.length - 1"
:aria-label="t('queue.moveDown')"
@click="moveDown(index)"
>
<AppIcon name="chevronDown" />
</button>
<button class="icon-btn" :disabled="busy" :aria-label="t('queue.remove')" @click="removeSong(index)">
<AppIcon name="trash" />
</button>
</template>
</li>
</ol>
</template>
<AppSheet :open="renaming" :title="t('playlists.rename')" @close="renaming = false">
<form class="form" @submit.prevent="rename">
<label class="field">
<span>{{ t('playlists.name') }}</span>
<input v-model="newName" class="input" maxlength="64" required autofocus />
</label>
<button class="btn btn--primary btn--block" :disabled="busy || !newName.trim()">{{ t('common.save') }}</button>
</form>
</AppSheet>
</div>
</template>
<style scoped>
.page-header {
justify-content: flex-start;
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.songs .icon-btn {
width: 36px;
}
@media (max-width: 480px) {
.songs .row {
gap: 6px;
}
.songs :deep(.cover) {
display: none;
}
}
.intro {
margin: -6px 0 0;
font-size: 0.875rem;
}
.form {
display: grid;
gap: 14px;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { api, type PlaylistSummary } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import AppSheet from '@/components/AppSheet.vue'
import { session } from '@/stores/session'
import { showError } from '@/stores/toast'
const { t } = useI18n()
const router = useRouter()
const playlists = ref<PlaylistSummary[] | null>(null)
const creating = ref(false)
const name = ref('')
const busy = ref(false)
onMounted(async () => {
playlists.value = await api.playlists().catch((e) => {
showError(e)
return []
})
})
async function create() {
if (!name.value.trim()) return
busy.value = true
try {
const playlist = await api.createPlaylist(name.value.trim())
creating.value = false
name.value = ''
router.push({ name: 'playlist', params: { id: playlist.id } })
} catch (e) {
showError(e)
} finally {
busy.value = false
}
}
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('playlists.title') }}</h1>
<button v-if="session.can('EditPlaylists')" class="btn btn--primary" @click="creating = true"><AppIcon name="plus" /> {{ t('playlists.new') }}</button>
</header>
<p class="muted intro">{{ t('playlists.intro') }}</p>
<p v-if="playlists === null" class="muted">{{ t('common.loading') }}</p>
<div v-else-if="playlists.length === 0" class="empty">
<AppIcon name="playlist" :size="40" />
<p>{{ t('playlists.empty') }}</p>
</div>
<nav v-else class="list">
<RouterLink v-for="p in playlists" :key="p.id" :to="{ name: 'playlist', params: { id: p.id } }" class="row">
<AppIcon name="playlist" />
<span class="row-text">
<span class="row-title">{{ p.name }}</span>
<span class="row-sub">{{ t('playlists.songs', p.songCount) }}</span>
</span>
<AppIcon name="right" class="muted" />
</RouterLink>
</nav>
<AppSheet :open="creating" :title="t('playlists.new')" @close="creating = false">
<form class="form" @submit.prevent="create">
<label class="field">
<span>{{ t('playlists.name') }}</span>
<input v-model="name" class="input" maxlength="64" required autofocus />
</label>
<button class="btn btn--primary btn--block" :disabled="busy || !name.trim()">{{ t('playlists.create') }}</button>
</form>
</AppSheet>
</div>
</template>
<style scoped>
.intro {
margin: -6px 0 0;
font-size: 0.875rem;
}
.form {
display: grid;
gap: 14px;
}
</style>
+251
View File
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type QueueEntry } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import AppSheet from '@/components/AppSheet.vue'
import ProfileAvatar from '@/components/ProfileAvatar.vue'
import QueueOptions, { type QueueChoice } from '@/components/QueueOptions.vue'
import SongCover from '@/components/SongCover.vue'
import { live, setQueue } from '@/stores/live'
import { session } from '@/stores/session'
import { showError, toast } from '@/stores/toast'
const { t } = useI18n()
const entries = computed(() => live.queue)
const busy = ref(false)
const editing = ref<QueueEntry | null>(null)
const choice = ref<QueueChoice>({ gameMode: 'Normal', audioMode: 'Normal', playerIds: [] })
const isMine = (entry: QueueEntry) => entry.requestedBy?.id === session.profile.value?.id
const canEdit = (entry: QueueEntry) => isMine(entry) || session.can('ManageQueue')
const canOpen = (entry: QueueEntry) => canEdit(entry) || session.can('StartSongs')
function edit(entry: QueueEntry) {
if (!canOpen(entry)) return
editing.value = entry
choice.value = { gameMode: entry.gameMode, audioMode: entry.audioMode, playerIds: entry.players.map((p) => p.id) }
}
async function run(action: () => Promise<QueueEntry[]>, message?: string) {
busy.value = true
try {
setQueue(await action())
if (message) toast(message)
return true
} catch (e) {
showError(e)
return false
} finally {
busy.value = false
}
}
async function save() {
const entry = editing.value
if (entry && (await run(() => api.updateQueueEntry(entry.id, choice.value), t('common.saved')))) editing.value = null
}
async function remove() {
const entry = editing.value
if (entry && (await run(() => api.dequeue(entry.id), t('queue.removed')))) editing.value = null
}
async function start() {
const entry = editing.value
if (!entry) return
busy.value = true
try {
setQueue(await api.startQueueEntry(entry.id))
toast(t('queue.started', { title: entry.song.title }))
editing.value = null
} catch (e) {
if (e instanceof ApiError && e.status === 409) toast(t('queue.cannotStart'), 'error')
else showError(e)
} finally {
busy.value = false
}
}
const move = (entry: QueueEntry, position: number) => run(() => api.moveInQueue(entry.id, position))
async function clear() {
if (window.confirm(t('queue.clearConfirm'))) await run(() => api.clearQueue(), t('queue.cleared'))
}
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('queue.title') }}</h1>
<div class="header-actions">
<RouterLink to="/help" class="icon-btn" :aria-label="t('more.help')"><AppIcon name="help" /></RouterLink>
<button v-if="session.isAdmin.value && entries?.length" class="btn btn--danger" :disabled="busy" @click="clear">
<AppIcon name="trash" :size="18" /> <span class="hide-small">{{ t('queue.clear') }}</span>
</button>
<RouterLink v-if="session.can('AddToQueue')" to="/songs" class="btn btn--primary" :aria-label="t('nav.songs')"><AppIcon name="plus" /> <span class="hide-small">{{ t('nav.songs') }}</span></RouterLink>
</div>
</header>
<p class="muted hint">{{ t('queue.fairHint') }}</p>
<p v-if="entries === null" class="muted">{{ t('common.loading') }}</p>
<div v-else-if="entries.length === 0" class="empty">
<AppIcon name="queue" :size="40" />
<p>{{ t('queue.empty') }}</p>
<p>{{ t('queue.emptyHint') }}</p>
</div>
<ol v-else class="list">
<li v-for="(entry, index) in entries" :key="entry.id" class="entry" :class="{ mine: isMine(entry) }">
<button class="row" :disabled="!canOpen(entry)" @click="edit(entry)">
<span class="pos">{{ index + 1 }}</span>
<SongCover :song-id="entry.song.id" :has-cover="entry.song.hasCover" />
<span class="row-text">
<span class="row-title">{{ entry.song.title }}</span>
<span class="row-sub">{{ entry.song.artist }}</span>
<span class="meta">
<span v-if="entry.requestedBy" class="who">
<ProfileAvatar :profile="{ id: entry.requestedBy.id, name: entry.requestedBy.name, hasAvatar: true }" :size="22" />
{{ entry.requestedBy.name }}
<span v-if="isMine(entry)" class="badge badge--accent">{{ t('queue.you') }}</span>
</span>
<span v-else class="muted">{{ t('queue.unknown') }}</span>
<span v-if="entry.gameMode !== 'Normal'" class="badge">{{ t(`gameModes.${entry.gameMode}`) }}</span>
<span v-if="entry.audioMode !== 'Normal'" class="badge">{{ t(`audioModes.${entry.audioMode}`) }}</span>
</span>
<span v-if="entry.players.length" class="row-sub">{{ t('queue.singers', { names: entry.players.map((p) => p.name).join(', ') }) }}</span>
</span>
<AppIcon v-if="canOpen(entry)" name="edit" :size="18" class="muted" />
</button>
<div v-if="session.can('ManageQueue')" class="move">
<button class="icon-btn" :disabled="busy || index === 0" :aria-label="t('queue.moveUp')" @click="move(entry, index - 1)">
<AppIcon name="chevronUp" />
</button>
<button class="icon-btn" :disabled="busy || index === entries.length - 1" :aria-label="t('queue.moveDown')" @click="move(entry, index + 1)">
<AppIcon name="chevronDown" />
</button>
</div>
</li>
</ol>
<AppSheet :open="editing !== null" :title="t('queue.edit')" @close="editing = null">
<template v-if="editing">
<div class="edit-song">
<SongCover :song-id="editing.song.id" :has-cover="editing.song.hasCover" :size="56" />
<span class="row-text">
<span class="row-title">{{ editing.song.title }}</span>
<span class="row-sub">{{ editing.song.artist }}</span>
</span>
</div>
<QueueOptions v-if="canEdit(editing)" v-model="choice" :song="editing.song" />
<button v-if="session.can('StartSongs')" class="btn btn--primary btn--block" :disabled="busy" @click="start">
<AppIcon name="mic" :size="18" /> {{ t('queue.startNow') }}
</button>
<div v-if="canEdit(editing)" class="edit-actions">
<button class="btn btn--danger" :disabled="busy" @click="remove"><AppIcon name="trash" :size="18" /> {{ t('queue.remove') }}</button>
<button class="btn" :disabled="busy" @click="save">{{ t('common.save') }}</button>
</div>
</template>
</AppSheet>
</div>
</template>
<style scoped>
.page-header h1 {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
flex: none;
gap: 8px;
}
.hint {
margin: -8px 0 0;
font-size: 0.875rem;
}
.entry {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
}
.entry:last-child {
border-bottom: 0;
}
.entry .row {
flex: 1;
min-width: 0;
border-bottom: 0;
}
.entry .row:disabled {
opacity: 1;
cursor: default;
}
.mine {
background: var(--accent-soft);
}
.pos {
width: 20px;
flex: none;
text-align: center;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 0.8rem;
}
.who {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.move {
display: flex;
flex-direction: column;
padding-right: 4px;
}
.move .icon-btn {
height: 32px;
}
.edit-song {
display: flex;
align-items: center;
gap: 12px;
}
.edit-actions {
display: flex;
justify-content: space-between;
gap: 10px;
}
@media (max-width: 480px) {
.hide-small {
display: none;
}
.header-actions .btn {
padding: 0 12px;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type RemoteKey } from '@/api/client'
import AppIcon, { type IconName } from '@/components/AppIcon.vue'
import { showError } from '@/stores/toast'
const { t } = useI18n()
const text = ref('')
const pad: { key: RemoteKey; icon: IconName; area: string }[] = [
{ key: 'Up', icon: 'chevronUp', area: 'up' },
{ key: 'Left', icon: 'left', area: 'left' },
{ key: 'Right', icon: 'right', area: 'right' },
{ key: 'Down', icon: 'chevronDown', area: 'down' }
]
const extraKeys: RemoteKey[] = ['Escape', 'Tab', 'Backspace', 'Space', 'PageUp', 'PageDown']
function send(key: RemoteKey) {
navigator.vibrate?.(10)
api.sendKey(key).catch(showError)
}
async function sendText() {
if (!text.value) return
try {
await api.sendText(text.value)
text.value = ''
} catch (e) {
showError(e)
}
}
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('remote.title') }}</h1>
</header>
<div class="pad">
<button v-for="p in pad" :key="p.key" class="pad-btn" :style="{ gridArea: p.area }" :aria-label="p.key" @click="send(p.key)">
<AppIcon :name="p.icon" :size="32" />
</button>
<button class="pad-btn pad-ok" style="grid-area: ok" @click="send('Enter')">{{ t('remote.keys.Enter') }}</button>
</div>
<div class="keys">
<button v-for="k in extraKeys" :key="k" class="btn" @click="send(k)">{{ t(`remote.keys.${k}`) }}</button>
</div>
<form class="text" @submit.prevent="sendText">
<label class="visually-hidden" for="remote-text">{{ t('remote.text') }}</label>
<input id="remote-text" v-model="text" class="input" maxlength="200" :placeholder="t('remote.text')" autocomplete="off" />
<button class="btn btn--primary" :disabled="!text">{{ t('remote.send') }}</button>
</form>
</div>
</template>
<style scoped>
.page {
max-width: 480px;
}
.pad {
display: grid;
grid-template-areas:
'. up .'
'left ok right'
'. down .';
grid-template-columns: repeat(3, 1fr);
gap: 10px;
aspect-ratio: 1;
max-width: 340px;
width: 100%;
margin: 0 auto;
}
.pad-btn {
display: grid;
place-items: center;
border: 0;
border-radius: 22px;
background: var(--surface-raised);
font-weight: 700;
font-size: 1.1rem;
touch-action: manipulation;
user-select: none;
}
.pad-btn:active {
background: var(--surface-hover);
transform: scale(0.97);
}
.pad-ok {
border-radius: 50%;
background: var(--accent);
color: #fff;
}
.pad-ok:active {
background: var(--accent);
filter: brightness(1.1);
}
.keys {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.keys .btn {
padding: 0 8px;
border-radius: var(--radius-small);
}
.text {
display: flex;
gap: 8px;
}
</style>
+326
View File
@@ -0,0 +1,326 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Song, type SongFacets, type SongSort } from '@/api/client'
import AppIcon from '@/components/AppIcon.vue'
import SongCover from '@/components/SongCover.vue'
import SongSheet from '@/components/SongSheet.vue'
import { preview, stopPreview, togglePreview } from '@/stores/preview'
import { storage } from '@/stores/storage'
import { showError } from '@/stores/toast'
const { t } = useI18n()
const sorts: SongSort[] = ['Artist', 'Title', 'Year']
const pageSize = 50
const sortKey = 'vlx.songSort'
const query = ref('')
const sort = ref<SongSort>(sorts.find((s) => s === storage.get(sortKey)) ?? 'Artist')
const filters = reactive({ language: '', genre: '', decade: '', duet: false })
const songs = ref<Song[]>([])
const total = ref(0)
const loading = ref(false)
const facets = ref<SongFacets | null>(null)
const selected = ref<Song | null>(null)
const sentinel = ref<HTMLElement>()
let page = 0
let generation = 0
let debounce: number | undefined
let observer: IntersectionObserver | undefined
const activeFilters = computed(() => {
const list: { key: keyof typeof filters; label: string }[] = []
if (filters.language) list.push({ key: 'language', label: filters.language })
if (filters.genre) list.push({ key: 'genre', label: filters.genre })
if (filters.decade) list.push({ key: 'decade', label: t('songs.decade', { decade: filters.decade }) })
if (filters.duet) list.push({ key: 'duet', label: t('songs.onlyDuets') })
return list
})
function resetFilters() {
Object.assign(filters, { language: '', genre: '', decade: '', duet: false })
}
async function load(reset: boolean) {
if (reset) {
page = 0
generation++
} else if (loading.value || songs.value.length >= total.value) {
return
}
const current = generation
loading.value = true
try {
const result = await api.songs({
query: query.value || undefined,
language: filters.language || undefined,
genre: filters.genre || undefined,
decade: filters.decade ? Number(filters.decade) : undefined,
duet: filters.duet || undefined,
sort: sort.value,
page,
pageSize
})
if (current !== generation) return
songs.value = reset ? result.items : [...songs.value, ...result.items]
total.value = result.total
page++
} catch (e) {
showError(e)
} finally {
if (current === generation) loading.value = false
}
}
watch(query, () => {
window.clearTimeout(debounce)
debounce = window.setTimeout(() => load(true), 250)
})
watch(filters, () => load(true))
watch(sort, () => {
storage.set(sortKey, sort.value)
load(true)
})
onMounted(() => {
load(true)
api
.facets()
.then((f) => (facets.value = f))
.catch(showError)
observer = new IntersectionObserver((e) => e[0]?.isIntersecting && load(false), { rootMargin: '600px' })
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => {
observer?.disconnect()
window.clearTimeout(debounce)
stopPreview()
})
</script>
<template>
<div class="page">
<header class="page-header">
<h1>{{ t('songs.title') }}</h1>
<span class="muted">{{ t('songs.count', total) }}</span>
</header>
<div class="toolbar">
<label class="search">
<AppIcon name="search" class="search-icon" />
<span class="visually-hidden">{{ t('songs.search') }}</span>
<input v-model="query" class="input" type="search" :placeholder="t('songs.search')" enterkeyhint="search" />
</label>
<div class="option-row">
<span class="muted option-label"><AppIcon name="filter" :size="16" /> {{ t('songs.filterLabel') }}</span>
<select v-model="filters.language" class="pill" :class="{ active: filters.language }" :aria-label="t('songs.language')">
<option value="">{{ t('songs.allLanguages') }}</option>
<option v-for="f in facets?.languages" :key="f.value" :value="f.value">{{ f.value }} ({{ f.count }})</option>
</select>
<select v-model="filters.genre" class="pill" :class="{ active: filters.genre }" :aria-label="t('songs.genre')">
<option value="">{{ t('songs.allGenres') }}</option>
<option v-for="f in facets?.genres" :key="f.value" :value="f.value">{{ f.value }} ({{ f.count }})</option>
</select>
<select v-model="filters.decade" class="pill" :class="{ active: filters.decade }" :aria-label="t('songs.decadeLabel')">
<option value="">{{ t('songs.allDecades') }}</option>
<option v-for="f in facets?.decades" :key="f.value" :value="f.value">{{ t('songs.decade', { decade: f.value }) }} ({{ f.count }})</option>
</select>
<label class="pill toggle" :class="{ active: filters.duet }">
<input v-model="filters.duet" type="checkbox" class="visually-hidden" />
<AppIcon name="duet" :size="16" /> {{ t('songs.onlyDuets') }}
</label>
<button v-if="activeFilters.length" class="pill reset" :aria-label="t('songs.clearFilters')" @click="resetFilters">
<AppIcon name="close" :size="16" />
</button>
</div>
<div class="option-row">
<span class="muted option-label"><AppIcon name="sort" :size="16" /> {{ t('songs.sortLabel') }}</span>
<div class="segments" role="radiogroup" :aria-label="t('songs.sortLabel')">
<label v-for="s in sorts" :key="s" class="segment" :class="{ active: sort === s }">
<input v-model="sort" type="radio" :value="s" class="visually-hidden" />
{{ t(`songs.sortBy.${s}`) }}
</label>
</div>
</div>
</div>
<ul v-if="songs.length" class="list">
<li v-for="song in songs" :key="song.id" class="song">
<button class="row" @click="selected = song">
<SongCover :song-id="song.id" :has-cover="song.hasCover" />
<span class="row-text">
<span class="row-title">{{ song.title }}</span>
<span class="row-sub">{{ song.artist }}<template v-if="song.year"> · {{ song.year }}</template></span>
</span>
<span v-if="song.isDuet" class="badge badge--accent">{{ t('songs.duet') }}</span>
</button>
<button
v-if="song.hasAudio"
class="icon-btn play"
:class="{ playing: preview.songId === song.id }"
:aria-label="preview.songId === song.id ? t('songs.stopPreview') : t('songs.preview')"
@click="togglePreview(song)"
>
<AppIcon :name="preview.songId === song.id ? 'stop' : 'play'" :size="20" />
</button>
</li>
</ul>
<div v-else-if="!loading" class="empty">
<AppIcon name="music" :size="40" />
<p>{{ t('songs.empty') }}</p>
</div>
<p v-if="loading" class="muted loading">{{ t('common.loading') }}</p>
<div ref="sentinel" aria-hidden="true"></div>
<SongSheet :song="selected" @close="selected = null" />
</div>
</template>
<style scoped>
.toolbar {
display: grid;
gap: 10px;
position: sticky;
top: 0;
z-index: 5;
margin: 0 calc(var(--gutter) * -1);
padding: 8px var(--gutter);
background: var(--bg);
}
.search {
position: relative;
}
.search .input {
padding-left: 44px;
border-radius: 999px;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
}
.option-label {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
min-width: 118px;
font-size: 0.875rem;
}
.pill {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
width: auto;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
select.pill {
appearance: none;
padding-right: 30px;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 16px) 52%,
calc(100% - 11px) 52%;
background-size: 5px 5px;
background-repeat: no-repeat;
}
.pill.active {
border-color: var(--accent);
background-color: var(--accent-soft);
color: var(--accent);
}
.pill.reset {
padding: 0 10px;
color: var(--text-muted);
}
.segments {
display: inline-flex;
flex: none;
padding: 3px;
border-radius: 999px;
background: var(--surface);
}
.segment {
position: relative;
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 14px;
border-radius: 999px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.segment.active {
background: var(--surface-raised);
color: var(--text);
}
.song {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
}
.song:last-child {
border-bottom: 0;
}
.song .row {
border-bottom: 0;
flex: 1;
min-width: 0;
}
.play {
margin-right: 6px;
color: var(--text-muted);
}
.play.playing {
color: var(--accent);
background: var(--accent-soft);
}
.loading {
text-align: center;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// During development (npm run dev) API calls are forwarded to a running Vocaluxe.
// Override the address with VOCALUXE_URL, e.g. VOCALUXE_URL=http://192.168.0.10:3000 npm run dev
const vocaluxe = process.env.VOCALUXE_URL ?? 'http://localhost:3000'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
},
server: {
host: true,
proxy: {
'/api': vocaluxe,
'/swagger': vocaluxe
}
},
build: {
outDir: 'dist',
emptyOutDir: true,
assetsDir: 'assets'
}
})