1238 lines
95 KiB
HTML
1238 lines
95 KiB
HTML
<div class="app-layout"
|
||
x-data
|
||
@keydown.window.space="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.toggle(); }"
|
||
@keydown.window.arrow-left="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(-5); }"
|
||
@keydown.window.arrow-right="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(5); }"
|
||
@keydown.window="if ((e=$event).ctrlKey && e.key==='k') { e.preventDefault(); document.getElementById('search-input')?.focus(); } else if (e.key==='/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); document.getElementById('search-input')?.focus(); }"
|
||
>
|
||
|
||
<div class="main-content">
|
||
<!-- Left Sidebar -->
|
||
<div class="sidebar-left">
|
||
<div class="user-widget" x-show="$store.user.profile" x-cloak>
|
||
<div class="user-widget-main">
|
||
<div class="user-avatar" x-text="$store.user.initials()"></div>
|
||
<div style="min-width:0">
|
||
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
|
||
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
||
</div>
|
||
<button class="user-logout-btn" @click="$store.user.logout()" title="{{ t.player_log_out }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
|
||
<polyline points="16 17 21 12 16 7"/>
|
||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="user-stats">
|
||
<button class="user-stat" @click="$store.history.open()">
|
||
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
|
||
<span class="user-stat-label">{{ t.player_plays_count }}</span>
|
||
</button>
|
||
<div class="user-stat">
|
||
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
|
||
<span class="user-stat-label">{{ t.player_likes_count }}</span>
|
||
</div>
|
||
<div class="user-stat">
|
||
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
|
||
<span class="user-stat-label">{{ t.player_listened }}</span>
|
||
</div>
|
||
</div>
|
||
<button class="lastfm-profile-action"
|
||
:class="$store.user.lastfmClass()"
|
||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||
@click="$store.user.handleLastfm()"
|
||
:title="$store.user.lastfmLabel()"
|
||
:aria-label="$store.user.lastfmLabel()">
|
||
<span class="lastfm-dot"></span>
|
||
<span class="lastfm-profile-text">
|
||
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
|
||
<span class="lastfm-profile-separator">·</span>
|
||
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div class="sidebar-header">
|
||
<h2>{{ t.player_library }}</h2>
|
||
</div>
|
||
<div class="sidebar-nav">
|
||
<div class="sidebar-nav-item"
|
||
:class="{ active: $store.library.view === 'artists' }"
|
||
@click="$store.library.goArtists()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||
{{ t.player_artists }}
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-section">
|
||
<div class="sidebar-section-title">
|
||
{{ t.player_following }}
|
||
<span x-show="$store.follows.artists.length > 0"
|
||
x-text="'(' + $store.follows.artists.length + ')'"></span>
|
||
</div>
|
||
<template x-if="$store.follows.artists.length === 0">
|
||
<div class="following-empty">{{ t.player_no_followed_artists }}</div>
|
||
</template>
|
||
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
|
||
<template x-for="artist in $store.follows.artists" :key="artist.id">
|
||
<div class="following-artist"
|
||
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
|
||
@click="$store.library.openArtist(artist.id)">
|
||
<div class="following-avatar">
|
||
<template x-if="artist.image_url">
|
||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||
</template>
|
||
<template x-if="!artist.image_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||
</template>
|
||
</div>
|
||
<div class="following-name" x-text="artist.name"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="playlist-list">
|
||
<template x-for="pl in $store.playlists.regularList()" :key="pl.id">
|
||
<div class="playlist-item-row">
|
||
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
|
||
<template x-if="pl.kind === 'likes'">
|
||
<span style="display:flex;align-items:center;gap:6px">
|
||
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
<span x-text="$store.playlists.displayTitle(pl)"></span>
|
||
</span>
|
||
</template>
|
||
<template x-if="pl.kind !== 'likes'">
|
||
<span x-text="$store.playlists.displayTitle(pl)"></span>
|
||
</template>
|
||
<span class="playlist-count" x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
|
||
</div>
|
||
<template x-if="pl.is_own && pl.kind === 'user'">
|
||
<div class="playlist-item-actions">
|
||
<button class="playlist-action-btn" @click.stop="$store.playlists.startRename(pl)" title="{{ t.player_rename }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
</button>
|
||
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="{{ t.player_delete }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<button class="sidebar-create-btn" @click="$store.playlists.showCreate()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
{{ t.player_new_playlist }}
|
||
</button>
|
||
<template x-if="$store.playlists.publishedList().length > 0">
|
||
<div class="playlist-public-section">
|
||
<div class="sidebar-section-title playlist-subtitle">{{ t.player_published_playlists }}</div>
|
||
<template x-for="pl in $store.playlists.publishedList()" :key="'published-' + pl.id">
|
||
<div class="playlist-item-row">
|
||
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id)">
|
||
<div class="playlist-title-line">
|
||
<span class="playlist-title-text" x-text="$store.playlists.displayTitle(pl)"></span>
|
||
<span class="playlist-public-badge">{{ t.player_public }}</span>
|
||
</div>
|
||
<div class="playlist-meta-line">
|
||
<span class="playlist-owner" x-show="pl.owner_name" x-text="'{{ t.player_by }} ' + pl.owner_name"></span>
|
||
<span x-show="pl.owner_name">·</span>
|
||
<span x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="sidebar-bottom">
|
||
<a href="/admin/">{{ t.player_admin_panel }}</a>
|
||
</div>
|
||
</div>
|
||
|
||
<template x-if="$store.mobile.libraryOpen">
|
||
<div class="mobile-library-backdrop" @click.self="$store.mobile.closeLibrary()" x-cloak>
|
||
<aside class="mobile-library-drawer">
|
||
<div class="mobile-drawer-head">
|
||
<div>
|
||
<div class="mobile-drawer-title">{{ t.player_library }}</div>
|
||
<div class="playlist-count">{{ t.player_playlists }} / {{ t.player_following }}</div>
|
||
</div>
|
||
<button class="mobile-list-action" @click="$store.mobile.closeLibrary()" title="{{ t.player_close }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="mobile-drawer-body">
|
||
<div class="mobile-drawer-section">
|
||
<div class="sidebar-nav-item"
|
||
:class="{ active: $store.library.view === 'artists' }"
|
||
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||
{{ t.player_artists }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mobile-drawer-section">
|
||
<div class="sidebar-section-title">
|
||
{{ t.player_following }}
|
||
<span x-show="$store.follows.artists.length > 0"
|
||
x-text="'(' + $store.follows.artists.length + ')'"></span>
|
||
</div>
|
||
<template x-if="$store.follows.artists.length === 0">
|
||
<div class="following-empty">{{ t.player_no_followed_artists }}</div>
|
||
</template>
|
||
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
|
||
<template x-for="artist in $store.follows.artists" :key="'mobile-follow-' + artist.id">
|
||
<div class="mobile-list-row">
|
||
<div class="following-artist"
|
||
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
|
||
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
||
<div class="following-avatar">
|
||
<template x-if="artist.image_url">
|
||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||
</template>
|
||
<template x-if="!artist.image_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||
</template>
|
||
</div>
|
||
<div class="following-name" x-text="artist.name"></div>
|
||
</div>
|
||
<button class="mobile-list-action"
|
||
@click.stop="$store.follows.toggle(artist.id)"
|
||
title="{{ t.player_unfollow_artist }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||
<circle cx="9" cy="7" r="4"/>
|
||
<line x1="17" y1="11" x2="23" y2="11"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mobile-drawer-section">
|
||
<div class="sidebar-section-title">{{ t.player_playlists }}</div>
|
||
<template x-for="pl in $store.playlists.regularList()" :key="'mobile-playlist-' + pl.id">
|
||
<div class="playlist-item-row">
|
||
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
|
||
<template x-if="pl.kind === 'likes'">
|
||
<span style="display:flex;align-items:center;gap:6px">
|
||
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
<span x-text="$store.playlists.displayTitle(pl)"></span>
|
||
</span>
|
||
</template>
|
||
<template x-if="pl.kind !== 'likes'">
|
||
<span x-text="$store.playlists.displayTitle(pl)"></span>
|
||
</template>
|
||
<span class="playlist-count" x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
|
||
</div>
|
||
<template x-if="pl.is_own && pl.kind === 'user'">
|
||
<div class="playlist-item-actions">
|
||
<button class="playlist-action-btn" @click.stop="$store.mobile.closeLibrary(); $store.playlists.startRename(pl)" title="{{ t.player_rename }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
</button>
|
||
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="{{ t.player_delete }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<button class="sidebar-create-btn" @click="$store.mobile.closeLibrary(); $store.playlists.showCreate()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
{{ t.player_new_playlist }}
|
||
</button>
|
||
<template x-if="$store.playlists.publishedList().length > 0">
|
||
<div class="playlist-public-section">
|
||
<div class="sidebar-section-title playlist-subtitle">{{ t.player_published_playlists }}</div>
|
||
<template x-for="pl in $store.playlists.publishedList()" :key="'mobile-published-' + pl.id">
|
||
<div class="playlist-item-row">
|
||
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
|
||
<div class="playlist-title-line">
|
||
<span class="playlist-title-text" x-text="$store.playlists.displayTitle(pl)"></span>
|
||
<span class="playlist-public-badge">{{ t.player_public }}</span>
|
||
</div>
|
||
<div class="playlist-meta-line">
|
||
<span class="playlist-owner" x-show="pl.owner_name" x-text="'{{ t.player_by }} ' + pl.owner_name"></span>
|
||
<span x-show="pl.owner_name">·</span>
|
||
<span x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Center Content -->
|
||
<div class="center-content" id="center-scroll">
|
||
<!-- Search / account bar -->
|
||
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
|
||
<button class="mobile-library-btn"
|
||
@click="$store.user.menuOpen = false; $store.mobile.toggleLibrary()"
|
||
title="{{ t.player_library }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20"/>
|
||
<path d="M4 4.5A2.5 2.5 0 016.5 2H20v20H6.5A2.5 2.5 0 014 19.5z"/>
|
||
</svg>
|
||
</button>
|
||
<div class="connection-alert"
|
||
x-show="$store.connection.disconnected"
|
||
x-cloak
|
||
:title="$store.connection.message()"
|
||
role="status"
|
||
aria-live="polite">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M2 8.82a15 15 0 0120 0"/>
|
||
<path d="M5 12.86a10 10 0 0114 0"/>
|
||
<path d="M8.5 16.43a5 5 0 017 0"/>
|
||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||
</svg>
|
||
<span class="connection-alert-text">{{ t.player_connection_lost }}</span>
|
||
</div>
|
||
<div class="search-bar">
|
||
<span class="search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||
<input id="search-input" type="text" placeholder="{{ t.player_search_placeholder }}"
|
||
x-model="$store.library.searchQuery"
|
||
@input.debounce.300ms="$store.library.search($store.library.searchQuery)"
|
||
@keydown.escape="$store.library.clearSearch(); $el.blur()">
|
||
<template x-if="$store.library.searchQuery">
|
||
<button class="search-clear" @click="$store.library.clearSearch()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</template>
|
||
<template x-if="!$store.library.searchQuery">
|
||
<span class="search-shortcut">Ctrl+K</span>
|
||
</template>
|
||
</div>
|
||
<button class="torrent-import-btn"
|
||
@click="$store.torrents.open()"
|
||
title="{{ t.player_import_torrent }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
||
<polyline points="7 10 12 15 17 10"/>
|
||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||
</svg>
|
||
</button>
|
||
<button class="mobile-account-chip"
|
||
x-show="$store.user.profile"
|
||
x-cloak
|
||
@click="$store.mobile.closeLibrary(); $store.user.menuOpen = !$store.user.menuOpen"
|
||
:title="$store.user.profile?.name || 'Account'">
|
||
<span class="user-avatar" x-text="$store.user.initials()"></span>
|
||
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
|
||
</button>
|
||
<div class="mobile-account-popover"
|
||
x-show="$store.user.menuOpen && $store.user.profile"
|
||
x-cloak>
|
||
<div class="user-widget-main">
|
||
<span class="user-avatar" x-text="$store.user.initials()"></span>
|
||
<div style="min-width:0">
|
||
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
|
||
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
||
</div>
|
||
</div>
|
||
<div class="user-stats">
|
||
<button class="user-stat" @click="$store.history.open(); $store.user.menuOpen = false">
|
||
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
|
||
<span class="user-stat-label">{{ t.player_plays_count }}</span>
|
||
</button>
|
||
<div class="user-stat">
|
||
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
|
||
<span class="user-stat-label">{{ t.player_likes_count }}</span>
|
||
</div>
|
||
<div class="user-stat">
|
||
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
|
||
<span class="user-stat-label">{{ t.player_listened }}</span>
|
||
</div>
|
||
</div>
|
||
<button class="lastfm-profile-action"
|
||
:class="$store.user.lastfmClass()"
|
||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||
@click="$store.user.handleLastfm()"
|
||
:title="$store.user.lastfmLabel()"
|
||
:aria-label="$store.user.lastfmLabel()">
|
||
<span class="lastfm-dot"></span>
|
||
<span class="lastfm-profile-text">
|
||
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
|
||
<span class="lastfm-profile-separator">·</span>
|
||
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
|
||
</span>
|
||
</button>
|
||
<button class="modal-btn modal-btn-primary mobile-account-logout"
|
||
@click="$store.user.logout()">
|
||
{{ t.player_log_out }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search Results -->
|
||
<template x-if="$store.library.view === 'search'">
|
||
<div>
|
||
<template x-if="$store.library.searchLoading">
|
||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||
</template>
|
||
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||
<div>
|
||
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<p>{{ t.player_no_results }}</p>
|
||
</div>
|
||
</template>
|
||
<!-- Artists section -->
|
||
<template x-if="$store.library.searchResults.artists.length > 0">
|
||
<div class="search-section">
|
||
<h2 class="search-section-title">{{ t.player_artists }}</h2>
|
||
<div class="search-artists-row">
|
||
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
||
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
||
<div class="search-artist-img">
|
||
<template x-if="artist.image_url">
|
||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||
</template>
|
||
<template x-if="!artist.image_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||
</template>
|
||
<button class="artist-follow-card-btn"
|
||
:class="{ followed: $store.follows.has(artist.id) }"
|
||
@click.stop="$store.follows.toggle(artist.id)"
|
||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||
<circle cx="9" cy="7" r="4"/>
|
||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="search-artist-name" x-text="artist.name"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Releases section -->
|
||
<template x-if="$store.library.searchResults.releases.length > 0">
|
||
<div class="search-section">
|
||
<h2 class="search-section-title">{{ t.player_releases }}</h2>
|
||
<div class="search-releases-row">
|
||
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
||
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
||
<div class="search-release-cover" style="position:relative">
|
||
<template x-if="release.cover_url">
|
||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||
</template>
|
||
<template x-if="!release.cover_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||
</template>
|
||
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="card-title" x-text="release.title"></div>
|
||
<div class="card-subtitle">
|
||
<span x-text="release.year || ''"></span>
|
||
<span x-text="release.release_type"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Tracks section -->
|
||
<template x-if="$store.library.searchResults.tracks.length > 0">
|
||
<div class="search-section">
|
||
<h2 class="search-section-title">{{ t.player_tracks }}</h2>
|
||
<div class="track-list-header">
|
||
<span>#</span>
|
||
<span>{{ t.player_title }}</span>
|
||
<span></span>
|
||
<span></span>
|
||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||
</div>
|
||
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
||
<div class="track-row"
|
||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||
@dblclick="$store.library.playSearchTrack(idx)">
|
||
<span class="track-num" x-text="idx + 1"></span>
|
||
<div class="track-info">
|
||
<div class="track-title" x-text="track.title"></div>
|
||
<div class="track-artists-inline">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<span></span>
|
||
<div class="track-actions">
|
||
<button class="track-action-btn info-btn popularity-info-btn"
|
||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||
:style="$store.library.popularityStyle(track)"
|
||
@click.stop="$store.library.openTrackInfo(track)"
|
||
:title="$store.library.trackInfoTitle(track)"
|
||
aria-label="{{ t.player_track_info }}">
|
||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||
</button>
|
||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||
</button>
|
||
</div>
|
||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Artists Grid -->
|
||
<template x-if="$store.library.view === 'artists'">
|
||
<div>
|
||
<h1 class="section-title">{{ t.player_artists }}</h1>
|
||
<div class="card-grid">
|
||
<template x-for="artist in $store.library.artists" :key="artist.id">
|
||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||
<div class="card-img">
|
||
<template x-if="artist.image_url">
|
||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||
</template>
|
||
<template x-if="!artist.image_url">
|
||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||
</template>
|
||
<button class="artist-follow-card-btn"
|
||
:class="{ followed: $store.follows.has(artist.id) }"
|
||
@click.stop="$store.follows.toggle(artist.id)"
|
||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||
<circle cx="9" cy="7" r="4"/>
|
||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="card-title" x-text="artist.name"></div>
|
||
<div class="card-subtitle" x-text="artist.release_count + ' {{ t.player_releases_count }} · ' + artist.track_count + ' {{ t.player_tracks_count }}'"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<template x-if="$store.library.loading">
|
||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||
</template>
|
||
<div id="artist-sentinel" style="height:1px"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Artist Detail -->
|
||
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
||
<div>
|
||
<div class="breadcrumb">
|
||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||
<span>/</span>
|
||
<span x-text="$store.library.currentArtist.name"></span>
|
||
</div>
|
||
<div class="artist-header">
|
||
<div class="artist-img">
|
||
<template x-if="$store.library.currentArtist.image_url">
|
||
<img :src="$store.library.currentArtist.image_url" :alt="$store.library.currentArtist.name">
|
||
</template>
|
||
<template x-if="!$store.library.currentArtist.image_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||
</template>
|
||
</div>
|
||
<div>
|
||
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
|
||
<div class="artist-stats">
|
||
<span x-text="$store.library.currentArtist.releases.length + ' {{ t.player_releases_count }}'"></span>
|
||
<span>•</span>
|
||
<span x-text="$store.library.currentArtist.total_track_count + ' {{ t.player_tracks_count }}'"></span>
|
||
<span>•</span>
|
||
<span x-text="$store.library.currentArtist.total_play_count + ' {{ t.player_plays_count }}'"></span>
|
||
</div>
|
||
<div class="release-actions artist-actions">
|
||
<button class="release-action-btn primary artist-listen-action"
|
||
:disabled="!($store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length)"
|
||
@click="$store.library.playArtistTopTracks()"
|
||
title="{{ t.player_listen_artist }}">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||
<path d="M8 5v14l11-7z"/>
|
||
</svg>
|
||
<span>{{ t.player_listen }}</span>
|
||
</button>
|
||
<button class="release-action-btn secondary artist-follow-action"
|
||
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
|
||
@click="$store.follows.toggle($store.library.currentArtist.id)"
|
||
:title="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||
<circle cx="9" cy="7" r="4"/>
|
||
<path x-show="!$store.follows.has($store.library.currentArtist.id)" d="M19 8v6M16 11h6"/>
|
||
<path x-show="$store.follows.has($store.library.currentArtist.id)" d="M16 11l2 2 4-5"/>
|
||
</svg>
|
||
<span x-text="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_followed }}' : '{{ t.player_follow }}'"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
||
<section class="artist-release-group">
|
||
<h2 class="artist-release-group-title" x-text="group.label"></h2>
|
||
<div class="card-grid">
|
||
<template x-for="release in group.releases" :key="release.id">
|
||
<div class="card" @click="$store.library.openRelease(release.id)">
|
||
<div class="card-img">
|
||
<template x-if="release.cover_url">
|
||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||
</template>
|
||
<template x-if="!release.cover_url">
|
||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||
</template>
|
||
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||
</button>
|
||
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="{{ t.player_add_to_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
</button>
|
||
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
|
||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="card-title" x-text="release.title"></div>
|
||
<div class="card-subtitle">
|
||
<span x-text="release.year || ''"></span>
|
||
<span x-text="release.track_count + ' {{ t.player_tracks_count }}'"></span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
|
||
<section class="artist-release-group">
|
||
<h2 class="artist-release-group-title">{{ t.player_appears_on }}</h2>
|
||
<div class="track-list-header">
|
||
<span>#</span>
|
||
<span>{{ t.player_title }}</span>
|
||
<span></span>
|
||
<span></span>
|
||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||
</div>
|
||
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
||
<div class="track-row"
|
||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
||
<span class="track-num" x-text="idx + 1"></span>
|
||
<div class="track-info">
|
||
<div class="track-title">
|
||
<span x-text="track.title"></span>
|
||
<span style="color:var(--text-subdued)"> · </span>
|
||
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
|
||
</div>
|
||
<div class="track-artists-inline">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<span></span>
|
||
<div class="track-actions">
|
||
<button class="track-action-btn info-btn popularity-info-btn"
|
||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||
:style="$store.library.popularityStyle(track)"
|
||
@click.stop="$store.library.openTrackInfo(track)"
|
||
:title="$store.library.trackInfoTitle(track)"
|
||
aria-label="{{ t.player_track_info }}">
|
||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||
</button>
|
||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||
</button>
|
||
</div>
|
||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Release Detail -->
|
||
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
||
<div>
|
||
<div class="breadcrumb">
|
||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||
<span>/</span>
|
||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
||
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||
</template>
|
||
<span>/</span>
|
||
<span x-text="$store.library.currentRelease.title"></span>
|
||
</div>
|
||
<div class="release-header">
|
||
<div class="release-cover">
|
||
<template x-if="$store.library.currentRelease.cover_url">
|
||
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
|
||
</template>
|
||
<template x-if="!$store.library.currentRelease.cover_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||
</template>
|
||
</div>
|
||
<div class="release-meta">
|
||
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
|
||
<div class="release-title-row">
|
||
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
|
||
<button class="like-btn like-btn-lg release-title-like"
|
||
:class="{ liked: $store.likes.isReleaseLiked($store.library.currentRelease) }"
|
||
@click.stop="$store.likes.toggleRelease($store.library.currentRelease.id)"
|
||
title="{{ t.player_like }}">
|
||
<svg viewBox="0 0 24 24" :fill="$store.likes.isReleaseLiked($store.library.currentRelease) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="release-artists">
|
||
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||
<div class="release-actions">
|
||
<button class="release-action-btn secondary"
|
||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||
aria-label="{{ t.player_release_info }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||
{{ t.player_info }}
|
||
</button>
|
||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||
{{ t.player_play }}
|
||
</button>
|
||
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
{{ t.player_queue }}
|
||
</button>
|
||
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
{{ t.player_next }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Track list -->
|
||
<div class="track-list-header">
|
||
<span>#</span>
|
||
<span>{{ t.player_title }}</span>
|
||
<span></span>
|
||
<span></span>
|
||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||
</div>
|
||
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
||
<div class="track-row"
|
||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||
@dblclick="$store.queue.playRelease([track], 0)">
|
||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||
<div class="track-info">
|
||
<div class="track-title" x-text="track.title"></div>
|
||
<div class="track-artists-inline">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<span></span>
|
||
<div class="track-actions">
|
||
<button class="track-action-btn info-btn popularity-info-btn"
|
||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||
:style="$store.library.popularityStyle(track)"
|
||
@click.stop="$store.library.openTrackInfo(track)"
|
||
:title="$store.library.trackInfoTitle(track)"
|
||
aria-label="{{ t.player_track_info }}">
|
||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||
</button>
|
||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||
</button>
|
||
</div>
|
||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Playlist Detail -->
|
||
<template x-if="$store.library.view === 'playlist_detail' && $store.library.currentPlaylist">
|
||
<div>
|
||
<div class="breadcrumb">
|
||
<a @click="$store.library.goArtists()">{{ t.player_library }}</a>
|
||
<span>/</span>
|
||
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
||
</div>
|
||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||
<div class="playlist-detail-meta"
|
||
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||
<span x-show="$store.library.currentPlaylist.owner_name"
|
||
x-text="'{{ t.player_by }} ' + $store.library.currentPlaylist.owner_name"></span>
|
||
<span x-show="$store.library.currentPlaylist.owner_name && $store.library.currentPlaylist.is_public">·</span>
|
||
<span class="playlist-public-badge"
|
||
x-show="$store.library.currentPlaylist.is_public">{{ t.player_published }}</span>
|
||
</div>
|
||
<template x-if="$store.library.currentPlaylist.description">
|
||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||
</template>
|
||
<div class="track-list-header">
|
||
<span>#</span>
|
||
<span>{{ t.player_title }}</span>
|
||
<span></span>
|
||
<span></span>
|
||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||
</div>
|
||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
||
<div class="track-row"
|
||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
||
<span class="track-num" x-text="idx + 1"></span>
|
||
<div class="track-info">
|
||
<div class="track-title" x-text="track.title"></div>
|
||
<div class="track-artists-inline">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<span></span>
|
||
<div class="track-actions">
|
||
<button class="track-action-btn info-btn popularity-info-btn"
|
||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||
:style="$store.library.popularityStyle(track)"
|
||
@click.stop="$store.library.openTrackInfo(track)"
|
||
:title="$store.library.trackInfoTitle(track)"
|
||
aria-label="{{ t.player_track_info }}">
|
||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||
</button>
|
||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||
</button>
|
||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||
</button>
|
||
</div>
|
||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Queue Panel -->
|
||
<div class="queue-backdrop"
|
||
x-show="$store.queue.visible"
|
||
x-cloak
|
||
@click="$store.queue.visible = false"></div>
|
||
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
||
<div class="queue-header">
|
||
<h3>{{ t.player_queue }}</h3>
|
||
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
||
</div>
|
||
<div class="queue-tracks">
|
||
<template x-if="$store.queue.tracks.length === 0">
|
||
<div class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||
<p>{{ t.player_queue_empty }}</p>
|
||
</div>
|
||
</template>
|
||
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
|
||
<div class="queue-track"
|
||
:data-queue-index="idx"
|
||
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx, 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
|
||
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
|
||
@click="$store.queue.playFromIndex(idx)"
|
||
draggable="true"
|
||
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
|
||
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
|
||
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
|
||
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
|
||
<div class="queue-drag-handle"
|
||
@mousedown.stop
|
||
@click.stop
|
||
@pointerdown.stop="$store.queue.startPointerReorder($event, idx)">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||
</div>
|
||
<div class="queue-track-cover">
|
||
<template x-if="track.cover_url">
|
||
<img :src="track.cover_url" :alt="track.title" loading="lazy">
|
||
</template>
|
||
<template x-if="!track.cover_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||
</template>
|
||
</div>
|
||
<div class="queue-track-info">
|
||
<div class="queue-track-title" x-text="track.title"></div>
|
||
<div class="queue-track-artist">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="queue-track-actions">
|
||
<button class="queue-track-remove info-btn popularity-info-btn"
|
||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||
:style="$store.library.popularityStyle(track)"
|
||
@click.stop="$store.library.openTrackInfo(track)"
|
||
:title="$store.library.trackInfoTitle(track)"
|
||
aria-label="{{ t.player_track_info }}">
|
||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||
</button>
|
||
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Player Bar -->
|
||
<div class="player-bar"
|
||
:class="{ 'mobile-expanded': $store.mobile.playerExpanded, 'mobile-dragging': $store.mobile.playerDragging }"
|
||
:style="$store.mobile.playerDragStyle()"
|
||
@click.capture="$store.mobile.handlePlayerClick($event)"
|
||
@pointerdown="$store.mobile.startPlayerDrag($event)">
|
||
<button class="mobile-player-collapse-btn" type="button" @click.stop="$store.mobile.closePlayerFullscreen()" title="{{ t.player_close }}" aria-label="{{ t.player_close }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||
<path d="M6 9l6 6 6-6"/>
|
||
</svg>
|
||
</button>
|
||
<div class="player-now-playing">
|
||
<template x-if="$store.player.currentTrack">
|
||
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
|
||
<div class="player-cover"
|
||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||
<template x-if="$store.player.currentTrack.cover_url">
|
||
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
|
||
</template>
|
||
<template x-if="!$store.player.currentTrack.cover_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||
</template>
|
||
</div>
|
||
<div class="player-track-info">
|
||
<div class="player-track-title-row">
|
||
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
|
||
<button class="like-btn player-current-like"
|
||
:class="{ liked: $store.likes.has($store.player.currentTrack.id) }"
|
||
@click.stop="$store.likes.toggle($store.player.currentTrack.id)"
|
||
title="{{ t.player_like }}"
|
||
aria-label="{{ t.player_like }}">
|
||
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="player-track-artist">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||
</span>
|
||
</template>
|
||
<template x-if="$store.player.currentTrack.release_year">
|
||
<span class="player-release-year" x-text="' · ' + $store.player.currentTrack.release_year"></span>
|
||
</template>
|
||
</div>
|
||
<div class="player-track-release" x-show="$store.player.currentTrack.release_title">
|
||
<a class="artist-link" @click.stop="$store.player.currentTrack.release_id && $store.library.openRelease($store.player.currentTrack.release_id)" x-text="$store.player.currentTrack.release_title"></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="player-controls">
|
||
<div class="player-buttons">
|
||
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||
</button>
|
||
<button class="player-btn" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||
</button>
|
||
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
||
<template x-if="!$store.player.isPlaying">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||
</template>
|
||
<template x-if="$store.player.isPlaying">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
||
</template>
|
||
</button>
|
||
<button class="player-btn" @click="$store.player.next()" title="{{ t.player_next }}">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||
</button>
|
||
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
||
<template x-if="$store.player.repeatMode !== 'one'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
|
||
</template>
|
||
<template x-if="$store.player.repeatMode === 'one'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="12" y="14" font-size="8" fill="currentColor" text-anchor="middle" font-weight="bold">1</text></svg>
|
||
</template>
|
||
</button>
|
||
</div>
|
||
<div class="player-timeline">
|
||
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
||
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
|
||
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
|
||
<div class="progress-bar-thumb"></div>
|
||
</div>
|
||
</div>
|
||
<div class="player-progress-strip-times"
|
||
x-text="'-' + formatTime(Math.max(0, $store.player.duration - $store.player.currentTime)) + ' / ' + formatTime($store.player.duration)"></div>
|
||
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
|
||
</div>
|
||
<div class="player-version-chip">v{{ t.app_version() }}</div>
|
||
</div>
|
||
|
||
<div class="player-right">
|
||
<div class="volume-control">
|
||
<button class="volume-btn" @click="$store.player.toggleMute()">
|
||
<template x-if="$store.player.volume === 0">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
||
</template>
|
||
<template x-if="$store.player.volume > 0 && $store.player.volume < 0.5">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
|
||
</template>
|
||
<template x-if="$store.player.volume >= 0.5">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
||
</template>
|
||
</button>
|
||
<div class="volume-slider"
|
||
@pointerdown.prevent="$store.player.startVolumeDrag($event)"
|
||
aria-label="{{ t.player_volume }}">
|
||
<div class="volume-slider-fill" :style="'width:' + $store.player.volumeSliderPercent() + '%'">
|
||
<div class="volume-slider-thumb"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="{{ t.player_queue }}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||
</button>
|
||
<div class="device-picker" @click.outside="$store.devices.open = false">
|
||
<button class="queue-toggle-btn device-toggle-btn"
|
||
:class="{ active: $store.devices.isActive() }"
|
||
@click="$store.devices.toggle()"
|
||
:title="$store.devices.activeLabel()"
|
||
aria-label="Devices">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||
<path d="M8 20h8"/>
|
||
<path d="M12 16v4"/>
|
||
</svg>
|
||
<span class="jam-member-squares"
|
||
x-show="$store.devices.activeJamMembers().length > 0"
|
||
x-cloak>
|
||
<template x-for="member in $store.devices.activeJamMembers().slice(0, 8)" :key="'device-jam-member-' + member.user_id">
|
||
<span class="jam-member-square" :style="$store.devices.userColorStyle(member.user_id, member.name)"></span>
|
||
</template>
|
||
</span>
|
||
</button>
|
||
<div class="device-popover" x-show="$store.devices.open" x-transition x-cloak>
|
||
<template x-for="device in $store.devices.devices" :key="device.id">
|
||
<button class="device-row"
|
||
:class="{ active: device.is_active, 'current-device': device.is_current }"
|
||
@click="$store.devices.select(device.id)">
|
||
<span class="device-row-icon">
|
||
<template x-if="device.kind === 'phone'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="7" y="2" width="10" height="20" rx="2"/>
|
||
<path d="M11 18h2"/>
|
||
</svg>
|
||
</template>
|
||
<template x-if="device.kind !== 'phone'">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||
<path d="M8 20h8"/>
|
||
<path d="M12 16v4"/>
|
||
</svg>
|
||
</template>
|
||
</span>
|
||
<span class="device-row-main">
|
||
<span class="device-row-name" x-text="device.name"></span>
|
||
<span class="device-row-current" x-show="device.is_current">This device</span>
|
||
</span>
|
||
<span class="device-row-check" x-show="device.is_active">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
</template>
|
||
<template x-if="$store.devices.jams.length > 0">
|
||
<div class="device-section-label jam-section-label">Jams</div>
|
||
</template>
|
||
<template x-for="jam in $store.devices.jams" :key="jam.id">
|
||
<button class="device-row jam-row"
|
||
:class="{ active: jam.is_active, pending: jam.is_pending }"
|
||
@click="$store.devices.handleJamRowClick(jam)">
|
||
<span class="device-row-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="8" cy="8" r="3"/>
|
||
<circle cx="16" cy="8" r="3"/>
|
||
<path d="M4 20v-1a4 4 0 014-4h0a4 4 0 014 4v1"/>
|
||
<path d="M12 20v-1a4 4 0 014-4h0a4 4 0 014 4v1"/>
|
||
</svg>
|
||
</span>
|
||
<span class="device-row-main">
|
||
<span class="device-row-name" x-text="jam.name"></span>
|
||
<span class="device-row-current"
|
||
x-text="!jam.host_device_online ? 'Host offline' : (jam.is_pending ? 'Invite pending' : (jam.is_owner ? 'Your Jam' : 'Shared queue'))"></span>
|
||
</span>
|
||
<span class="device-row-check" x-show="jam.is_active">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
</template>
|
||
<button class="device-row start-jam-row" x-show="!$store.devices.hasJoinedJam()" @click="$store.devices.openJamPanel()">
|
||
<span class="device-row-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14"/>
|
||
<path d="M5 12h14"/>
|
||
</svg>
|
||
</span>
|
||
<span class="device-row-main">
|
||
<span class="device-row-name">Start Jam</span>
|
||
<span class="device-row-current">Invite listeners</span>
|
||
</span>
|
||
</button>
|
||
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
|
||
<div class="jam-panel-title" x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite listeners' : 'Start Jam'"></div>
|
||
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
|
||
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
||
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
||
<span x-text="user.display_name || user.username"></span>
|
||
<span aria-hidden="true">×</span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<input class="jam-user-search"
|
||
type="search"
|
||
placeholder="Search users"
|
||
x-model="$store.devices.jamQuery"
|
||
@input="$store.devices.queueJamSearch()">
|
||
<div class="jam-search-results" x-show="$store.devices.jamUsers.length > 0">
|
||
<template x-for="user in $store.devices.jamUsers" :key="user.id">
|
||
<button class="jam-search-row" @click="$store.devices.addJamInvitee(user)">
|
||
<span x-text="user.display_name || user.username"></span>
|
||
<small x-text="user.email || user.username"></small>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<div class="jam-panel-actions">
|
||
<button class="jam-create-btn"
|
||
:disabled="$store.devices.jamSelectedUsers.length === 0 && $store.devices.jamPanelMode === 'manage'"
|
||
@click="$store.devices.submitJamPanel()"
|
||
x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite' : 'Create Jam'"></button>
|
||
<button class="jam-leave-btn"
|
||
x-show="$store.devices.jamPanelMode === 'manage'"
|
||
@click="$store.devices.leaveJam($store.devices.jamPanelJamId)">Leave</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mobile-expanded-queue">
|
||
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
|
||
<template x-if="$store.queue.upcoming().length === 0">
|
||
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
|
||
</template>
|
||
<template x-for="(track, idx) in $store.queue.upcoming()" :key="'mobile-expanded-queue-' + track.id + '-' + idx">
|
||
<button class="mobile-expanded-queue-row"
|
||
:class="{ 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
|
||
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
|
||
type="button"
|
||
@click="$store.queue.playFromIndex($store.queue.currentIndex + idx + 1)">
|
||
<div class="mobile-expanded-queue-cover">
|
||
<template x-if="track.cover_url">
|
||
<img :src="track.cover_url" :alt="track.title" loading="lazy">
|
||
</template>
|
||
<template x-if="!track.cover_url">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||
</template>
|
||
</div>
|
||
<div class="mobile-expanded-queue-info">
|
||
<div class="mobile-expanded-queue-name" x-text="track.title"></div>
|
||
<div class="mobile-expanded-queue-artist">
|
||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||
<span>
|
||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||
<span x-text="artist.label"></span>
|
||
</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<span class="mobile-expanded-queue-time" x-text="formatTime(track.duration_seconds)"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|