mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
feat(library): add album and artist scanning with cover art
This commit is contained in:
7
src-tauri/Cargo.lock
generated
7
src-tauri/Cargo.lock
generated
@@ -1600,6 +1600,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -3987,6 +3993,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'",
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
80
src/lib/components/TrackList.svelte
Normal file
80
src/lib/components/TrackList.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tracks: Track[];
|
||||||
|
onTrackSelect?: (track: Track) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tracks, onTrackSelect }: Props = $props();
|
||||||
|
|
||||||
|
let selectedIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
function handleRowClick(index: number) {
|
||||||
|
selectedIndex = index;
|
||||||
|
if (onTrackSelect) {
|
||||||
|
onTrackSelect(tracks[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds?: number): string {
|
||||||
|
if (!seconds) return '--:--';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sunken-panel track-list-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === i}
|
||||||
|
onclick={() => handleRowClick(i)}
|
||||||
|
>
|
||||||
|
<td>{track.metadata.trackNumber ?? i + 1}</td>
|
||||||
|
<td>{track.metadata.title ?? track.filename}</td>
|
||||||
|
<td>{track.metadata.artist ?? 'Unknown Artist'}</td>
|
||||||
|
<td>{track.metadata.album ?? 'Unknown Album'}</td>
|
||||||
|
<td>{formatDuration(track.metadata.duration)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.track-list-container {
|
||||||
|
height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:first-child {
|
||||||
|
width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:last-child {
|
||||||
|
width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { readDir } from '@tauri-apps/plugin-fs';
|
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
||||||
|
import { parseBuffer } from 'music-metadata';
|
||||||
|
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,6 +40,236 @@ export async function scanArtists(musicFolderPath: string): Promise<Artist[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find cover art image in album directory
|
||||||
|
*/
|
||||||
|
async function findAlbumArt(albumPath: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const entries = await readDir(albumPath);
|
||||||
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG'];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasImageExt) {
|
||||||
|
return `${albumPath}/${entry.name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding album art:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio format from file extension
|
||||||
|
*/
|
||||||
|
function getAudioFormat(filename: string): AudioFormat {
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
switch (ext) {
|
||||||
|
case 'flac':
|
||||||
|
return 'flac';
|
||||||
|
case 'mp3':
|
||||||
|
return 'mp3';
|
||||||
|
case 'opus':
|
||||||
|
return 'opus';
|
||||||
|
case 'ogg':
|
||||||
|
return 'ogg';
|
||||||
|
case 'm4a':
|
||||||
|
return 'm4a';
|
||||||
|
case 'wav':
|
||||||
|
return 'wav';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read year from first audio file in album directory
|
||||||
|
*/
|
||||||
|
async function getAlbumYear(albumPath: string): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const entries = await readDir(albumPath);
|
||||||
|
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3'];
|
||||||
|
|
||||||
|
// Find first audio file
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasAudioExt) {
|
||||||
|
const filePath = `${albumPath}/${entry.name}`;
|
||||||
|
const format = getAudioFormat(entry.name);
|
||||||
|
|
||||||
|
// Read file and parse metadata
|
||||||
|
const fileData = await readFile(filePath);
|
||||||
|
const mimeMap: Record<AudioFormat, string> = {
|
||||||
|
'flac': 'audio/flac',
|
||||||
|
'mp3': 'audio/mpeg',
|
||||||
|
'opus': 'audio/opus',
|
||||||
|
'ogg': 'audio/ogg',
|
||||||
|
'm4a': 'audio/mp4',
|
||||||
|
'wav': 'audio/wav',
|
||||||
|
'unknown': 'audio/mpeg'
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = await parseBuffer(fileData, mimeMap[format]);
|
||||||
|
return metadata.common.year;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading album year:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count audio files in album directory
|
||||||
|
*/
|
||||||
|
async function countTracks(albumPath: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const entries = await readDir(albumPath);
|
||||||
|
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3'];
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext));
|
||||||
|
if (hasAudioExt) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error counting tracks:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan all albums in music folder
|
||||||
|
*/
|
||||||
|
export async function scanAlbums(musicFolderPath: string): Promise<Album[]> {
|
||||||
|
try {
|
||||||
|
const artistEntries = await readDir(musicFolderPath);
|
||||||
|
const albums: Album[] = [];
|
||||||
|
|
||||||
|
for (const artistEntry of artistEntries) {
|
||||||
|
// Skip _temp folder and non-directories
|
||||||
|
if (!artistEntry.isDirectory || artistEntry.name === '_temp') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistPath = `${musicFolderPath}/${artistEntry.name}`;
|
||||||
|
const albumEntries = await readDir(artistPath);
|
||||||
|
|
||||||
|
for (const albumEntry of albumEntries) {
|
||||||
|
if (albumEntry.isDirectory) {
|
||||||
|
const albumPath = `${artistPath}/${albumEntry.name}`;
|
||||||
|
const trackCount = await countTracks(albumPath);
|
||||||
|
|
||||||
|
// Only include if there are tracks
|
||||||
|
if (trackCount > 0) {
|
||||||
|
const [coverArtPath, year] = await Promise.all([
|
||||||
|
findAlbumArt(albumPath),
|
||||||
|
getAlbumYear(albumPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
albums.push({
|
||||||
|
artist: artistEntry.name,
|
||||||
|
title: albumEntry.name,
|
||||||
|
path: albumPath,
|
||||||
|
coverArtPath,
|
||||||
|
trackCount,
|
||||||
|
year
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by artist then album title
|
||||||
|
albums.sort((a, b) => {
|
||||||
|
const artistCompare = a.artist.localeCompare(b.artist);
|
||||||
|
return artistCompare !== 0 ? artistCompare : a.title.localeCompare(b.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
return albums;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scanning albums:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan artists with their albums
|
||||||
|
*/
|
||||||
|
export async function scanArtistsWithAlbums(musicFolderPath: string): Promise<ArtistWithAlbums[]> {
|
||||||
|
try {
|
||||||
|
const artistEntries = await readDir(musicFolderPath);
|
||||||
|
const artists: ArtistWithAlbums[] = [];
|
||||||
|
|
||||||
|
for (const artistEntry of artistEntries) {
|
||||||
|
// Skip _temp folder and non-directories
|
||||||
|
if (!artistEntry.isDirectory || artistEntry.name === '_temp') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistPath = `${musicFolderPath}/${artistEntry.name}`;
|
||||||
|
const albumEntries = await readDir(artistPath);
|
||||||
|
const albums: Album[] = [];
|
||||||
|
|
||||||
|
for (const albumEntry of albumEntries) {
|
||||||
|
if (albumEntry.isDirectory) {
|
||||||
|
const albumPath = `${artistPath}/${albumEntry.name}`;
|
||||||
|
const trackCount = await countTracks(albumPath);
|
||||||
|
|
||||||
|
// Only include if there are tracks
|
||||||
|
if (trackCount > 0) {
|
||||||
|
const [coverArtPath, year] = await Promise.all([
|
||||||
|
findAlbumArt(albumPath),
|
||||||
|
getAlbumYear(albumPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
albums.push({
|
||||||
|
artist: artistEntry.name,
|
||||||
|
title: albumEntry.name,
|
||||||
|
path: albumPath,
|
||||||
|
coverArtPath,
|
||||||
|
trackCount,
|
||||||
|
year
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add artist if they have albums
|
||||||
|
if (albums.length > 0) {
|
||||||
|
albums.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
|
artists.push({
|
||||||
|
name: artistEntry.name,
|
||||||
|
path: artistPath,
|
||||||
|
albums,
|
||||||
|
primaryCoverArt: albums[0]?.coverArtPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by artist name
|
||||||
|
artists.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return artists;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scanning artists with albums:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan playlists folder for m3u/m3u8 files
|
* Scan playlists folder for m3u/m3u8 files
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,6 +29,28 @@ export interface Track {
|
|||||||
metadata: TrackMetadata;
|
metadata: TrackMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Album information
|
||||||
|
*/
|
||||||
|
export interface Album {
|
||||||
|
artist: string; // Album artist
|
||||||
|
title: string; // Album title
|
||||||
|
year?: number;
|
||||||
|
path: string; // Full path to album directory
|
||||||
|
coverArtPath?: string; // Path to cover art image if found
|
||||||
|
trackCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artist with their albums
|
||||||
|
*/
|
||||||
|
export interface ArtistWithAlbums {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
albums: Album[];
|
||||||
|
primaryCoverArt?: string; // Cover art from first album for thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playlist with tracks
|
* Playlist with tracks
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: 0;
|
||||||
font-family: "Pixelated MS Sans Serif", Arial;
|
font-family: "Pixelated MS Sans Serif", Arial;
|
||||||
background: #121212;
|
background: #121212;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div style="padding: 8px;">
|
||||||
<img src="/Square150x150Logo.png" alt="cat" style="width: 128px; height: 128px;" />
|
<img src="/Square150x150Logo.png" alt="cat" style="width: 128px; height: 128px;" />
|
||||||
<h2>Welcome to Shark!</h2>
|
<h2>Welcome to Shark!</h2>
|
||||||
<p>Your music library manager and player.</p>
|
<p>Your music library manager and player.</p>
|
||||||
|
|||||||
@@ -108,9 +108,9 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.downloads-page {
|
.downloads-page {
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
import { scanArtists, type Artist } from '$lib/library/scanner';
|
import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner';
|
||||||
|
import type { ArtistWithAlbums, Album } from '$lib/types/track';
|
||||||
|
|
||||||
let artists = $state<Artist[]>([]);
|
type ViewMode = 'artists' | 'albums';
|
||||||
|
|
||||||
|
let viewMode = $state<ViewMode>('artists');
|
||||||
|
let artists = $state<ArtistWithAlbums[]>([]);
|
||||||
|
let albums = $state<Album[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let selectedArtistIndex = $state<number | null>(null);
|
||||||
|
let selectedAlbumIndex = $state<number | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
await loadArtists();
|
await loadLibrary();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadArtists() {
|
async function loadLibrary() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
@@ -23,66 +31,208 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
artists = await scanArtists($settings.musicFolder);
|
const [artistsData, albumsData] = await Promise.all([
|
||||||
|
scanArtistsWithAlbums($settings.musicFolder),
|
||||||
|
scanAlbums($settings.musicFolder)
|
||||||
|
]);
|
||||||
|
|
||||||
|
artists = artistsData;
|
||||||
|
albums = albumsData;
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Error loading artists: ' + (e as Error).message;
|
error = 'Error loading library: ' + (e as Error).message;
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getThumbnailUrl(coverArtPath?: string): string {
|
||||||
|
if (!coverArtPath) {
|
||||||
|
return ''; // Will use CSS background for placeholder
|
||||||
|
}
|
||||||
|
return convertFileSrc(coverArtPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleArtistClick(index: number) {
|
||||||
|
selectedArtistIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumClick(index: number) {
|
||||||
|
selectedAlbumIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalTracks(artist: ArtistWithAlbums): number {
|
||||||
|
return artist.albums.reduce((sum, album) => sum + album.trackCount, 0);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Library</h2>
|
<h2 style="padding: 8px 8px 0 8px;">Library</h2>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p>Loading artists...</p>
|
<p>Loading library...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error">{error}</p>
|
<p class="error">{error}</p>
|
||||||
{:else if artists.length === 0}
|
{:else if artists.length === 0 && albums.length === 0}
|
||||||
<p>No artists found in your music folder.</p>
|
<p>No music found in your music folder.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="library-content">
|
<section class="library-content">
|
||||||
<table>
|
<!-- Tabs -->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'artists'}>
|
||||||
|
<button onclick={() => viewMode = 'artists'}>Artists</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'albums'}>
|
||||||
|
<button onclick={() => viewMode = 'albums'}>Albums</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'artists'}
|
||||||
|
<!-- Artists View -->
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="cover-column"></th>
|
||||||
<th>Artist</th>
|
<th>Artist</th>
|
||||||
<th>Path</th>
|
<th>Albums</th>
|
||||||
|
<th>Tracks</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each artists as artist}
|
{#each artists as artist, i}
|
||||||
<tr>
|
<tr
|
||||||
|
class:highlighted={selectedArtistIndex === i}
|
||||||
|
onclick={() => handleArtistClick(i)}
|
||||||
|
>
|
||||||
|
<td class="cover-cell">
|
||||||
|
{#if artist.primaryCoverArt}
|
||||||
|
<img
|
||||||
|
src={getThumbnailUrl(artist.primaryCoverArt)}
|
||||||
|
alt="{artist.name} cover"
|
||||||
|
class="thumbnail"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="thumbnail-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td>{artist.name}</td>
|
<td>{artist.name}</td>
|
||||||
<td class="path">{artist.path}</td>
|
<td>{artist.albums.length}</td>
|
||||||
|
<td>{getTotalTracks(artist)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Albums View -->
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="cover-column"></th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Tracks</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each albums as album, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedAlbumIndex === i}
|
||||||
|
onclick={() => handleAlbumClick(i)}
|
||||||
|
>
|
||||||
|
<td class="cover-cell">
|
||||||
|
{#if album.coverArtPath}
|
||||||
|
<img
|
||||||
|
src={getThumbnailUrl(album.coverArtPath)}
|
||||||
|
alt="{album.title} cover"
|
||||||
|
class="thumbnail"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="thumbnail-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{album.title}</td>
|
||||||
|
<td>{album.artist}</td>
|
||||||
|
<td>{album.year ?? '—'}</td>
|
||||||
|
<td>{album.trackCount}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-content {
|
.library-content {
|
||||||
margin-top: 20px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.path {
|
th {
|
||||||
font-family: monospace;
|
text-align: left;
|
||||||
font-size: 0.9em;
|
}
|
||||||
opacity: 0.7;
|
|
||||||
|
.cover-column {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-cell {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
image-rendering: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:nth-child(3),
|
||||||
|
td:nth-child(4),
|
||||||
|
td:nth-child(5) {
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div style="padding: 8px;">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
|
|
||||||
<menu role="tablist">
|
<menu role="tablist">
|
||||||
|
|||||||
Reference in New Issue
Block a user