This commit is contained in:
2026-08-18 14:38:00 +02:00
parent f54ebec0eb
commit d867a60126
14 changed files with 325 additions and 64 deletions
+15
View File
@@ -0,0 +1,15 @@
name: musify-share
# Temporary overlay for exposing the stack outside the LAN, on top of
# compose.dev.yml. Only difference from a normal dev run: Zitadel writes its
# init PAT to a separate host directory, so it never overwrites
# zitadel/.output (used by the musify-dev project's own Zitadel instance).
#
# docker compose --env-file deploy/.env.share \
# -f deploy/compose.yml -f deploy/compose.dev.yml -f deploy/compose.share.yml \
# --profile apps up -d --build api gateway web-player
services:
zitadel:
volumes:
- ./zitadel/.output-share:/output
@@ -0,0 +1 @@
eyJhbGciOiJBMjU2R0NNS1ciLCJlbmMiOiJBMjU2R0NNIiwiaXYiOiJ5Yk1hUFdVUmNqcU44WDNGIiwia2lkIjoib2lkY0tleSIsInRhZyI6Ikw2NjYwN3FRVzZNYVJLaVl3Q0w1N3cifQ.BE_7qqA1Auer5yEoGnyN0v35JlZlK3XgCo0s_CQtG7s.cOf9oudMHRnVTOGj.0DRh9kj7oahl4imvMjxqRLUOZo6NhYlQyslf9_x4XF_TKi1dnQ.Uu36qEL5J1VKSIYcvWlbAg
+44 -33
View File
@@ -15,7 +15,14 @@
param( param(
[string]$ProjectName = "Musify", [string]$ProjectName = "Musify",
[string]$ApiAppName = "Musify API (Scalar)", [string]$ApiAppName = "Musify API (Scalar)",
[string]$WebAppName = "Musify Web" [string]$WebAppName = "Musify Web",
# Alternate .env to read/write (e.g. deploy/.env.share). Defaults to deploy/.env.
[string]$EnvFile,
# Skip writing Musify.Api user-secrets and web-player/.env: use when the apps
# only ever run in Docker for this environment and get their config from compose.
[switch]$SkipHostWiring,
# Matches the zitadel volume mount for this environment (see compose.share.yml).
[string]$OutputDir = ".output"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -23,8 +30,8 @@ $deployDir = Split-Path $PSScriptRoot -Parent
$repoDir = Split-Path $deployDir -Parent $repoDir = Split-Path $deployDir -Parent
$apiProject = Join-Path $repoDir "backend\Musify.Api" $apiProject = Join-Path $repoDir "backend\Musify.Api"
$webEnvFile = Join-Path $repoDir "web-player\.env" $webEnvFile = Join-Path $repoDir "web-player\.env"
$envFile = Join-Path $deployDir ".env" $envFile = if ($EnvFile) { $EnvFile } else { Join-Path $deployDir ".env" }
$patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat" $patFile = Join-Path $PSScriptRoot "$OutputDir\admin-sa.pat"
. (Join-Path $deployDir "scripts\common.ps1") . (Join-Path $deployDir "scripts\common.ps1")
@@ -148,35 +155,39 @@ Set-DotEnvValue $envFile "AUTH_CLIENT_ID" $apiClientId
Set-DotEnvValue $envFile "WEB_CLIENT_ID" $webClientId Set-DotEnvValue $envFile "WEB_CLIENT_ID" $webClientId
Set-DotEnvValue $envFile "WEB_CLIENT_SECRET" $webSecret Set-DotEnvValue $envFile "WEB_CLIENT_SECRET" $webSecret
# Musify.Api reads these from user-secrets when it runs outside Docker. if ($SkipHostWiring) {
@{ Write-Host " wired into $envFile only (-SkipHostWiring: apps run in Docker)"
"Authentication:ClientId" = $apiClientId } else {
"Authentication:AudienceAddress" = $apiClientId # Musify.Api reads these from user-secrets when it runs outside Docker.
"Authentication:IssuerAddress" = $issuer @{
"Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration" "Authentication:ClientId" = $apiClientId
"Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login" "Authentication:AudienceAddress" = $apiClientId
"Authentication:TokenEndpoint" = "$issuer/oauth/v2/token" "Authentication:IssuerAddress" = $issuer
"Authentication:ScalarRedirectUri" = "$apiUrl/scalar/" "Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration"
"Authentication:RequireHttpsMetadata" = "false" "Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login"
}.GetEnumerator() | ForEach-Object { "Authentication:TokenEndpoint" = "$issuer/oauth/v2/token"
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null "Authentication:ScalarRedirectUri" = "$apiUrl/scalar/"
} "Authentication:RequireHttpsMetadata" = "false"
}.GetEnumerator() | ForEach-Object {
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null
}
# web-player/.env is what `npm run dev` reads (the container gets its config # web-player/.env is what `npm run dev` reads (the container gets its config
# from compose instead). # from compose instead).
if (-not (Test-Path $webEnvFile)) { if (-not (Test-Path $webEnvFile)) {
Copy-Item (Join-Path $repoDir "web-player\.env.example") $webEnvFile Copy-Item (Join-Path $repoDir "web-player\.env.example") $webEnvFile
} }
@{ @{
ZITADEL_ISSUER = $issuer ZITADEL_ISSUER = $issuer
ZITADEL_CLIENT_ID = $webClientId ZITADEL_CLIENT_ID = $webClientId
ZITADEL_CLIENT_SECRET = $webSecret ZITADEL_CLIENT_SECRET = $webSecret
AUTH_REDIRECT_URI = "$viteUrl/auth/callback" AUTH_REDIRECT_URI = "$viteUrl/auth/callback"
AUTH_POST_LOGOUT_URI = "$viteUrl/" AUTH_POST_LOGOUT_URI = "$viteUrl/"
SESSION_SECRET = $cfg['SESSION_SECRET'] SESSION_SECRET = $cfg['SESSION_SECRET']
API_BASE_URL = $apiUrl API_BASE_URL = $apiUrl
}.GetEnumerator() | ForEach-Object { }.GetEnumerator() | ForEach-Object {
Set-DotEnvValue $webEnvFile $_.Key $_.Value Set-DotEnvValue $webEnvFile $_.Key $_.Value
} }
Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env" Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env"
}
+89 -14
View File
@@ -37,6 +37,29 @@
"vite": "^8.0.16" "vite": "^8.0.16"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1098,7 +1121,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/acorn-typescript": "^1.0.9",
@@ -1150,7 +1172,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -1388,6 +1409,72 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@@ -1479,7 +1566,6 @@
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1541,7 +1627,6 @@
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0", "@typescript-eslint/types": "8.64.0",
@@ -1649,7 +1734,6 @@
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -1733,7 +1817,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1968,7 +2051,6 @@
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -3047,7 +3129,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3181,7 +3262,6 @@
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -3198,7 +3278,6 @@
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
@@ -3369,7 +3448,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -3504,7 +3582,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3716,7 +3793,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3778,7 +3854,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
+30
View File
@@ -0,0 +1,30 @@
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
interface YouTubeAlbumTracksResponse {
album: { artist: string; thumbnailUrl: string };
tracks: { videoId: string; title: string; isExplicit: boolean }[];
}
export async function fetchAlbumQueueItems(
kind: 'local' | 'youtube',
albumId: string
): Promise<QueueItem[]> {
if (kind === 'local') {
const res = await fetch(`/api/albums/${albumId}/tracks`);
if (!res.ok) return [];
const data = (await res.json()) as { items: ApiTrackLike[] };
return toQueueItems(data.items ?? []);
}
const res = await fetch(`/api/albums/youtube/${albumId}/tracks`);
if (!res.ok) return [];
const detail = (await res.json()) as YouTubeAlbumTracksResponse;
return detail.tracks.map((track) => ({
id: track.videoId,
title: track.title,
artist: detail.album.artist,
source: 'youtube' as const,
coverUrl: detail.album.thumbnailUrl,
explicit: track.isExplicit
}));
}
@@ -25,15 +25,19 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
interface Props { interface Props {
menu: AlbumMenuState; menu: AlbumMenuState;
playlists: { id: string; name: string }[]; playlists: { id: string; name: string }[];
onClose: () => void; onClose: () => void;
onPlayNext: () => void;
onAddToQueue: () => void;
} }
let { menu, playlists, onClose }: Props = $props(); let { menu, playlists, onClose, onPlayNext, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose(); if (event.key === 'Escape') onClose();
@@ -55,6 +59,22 @@
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu" class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;" style="left:{menu.x}px; top:{menu.y}px;"
> >
<button
type="button"
onclick={onPlayNext}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
</button>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
>
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} />
<span class="text-base">Añadir a la cola</span>
</button>
{#if playlists.length > 0} {#if playlists.length > 0}
<div class="group/addmenu relative"> <div class="group/addmenu relative">
<div <div
@@ -45,7 +45,7 @@
: ''}" : ''}"
></div> ></div>
<div <div
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300 group-hover/tile:opacity-100" class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
></div> ></div>
<div <div
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
+16 -2
View File
@@ -342,15 +342,29 @@ class PlayerState {
} }
addToQueue(item: QueueItem) { addToQueue(item: QueueItem) {
this.playNext([item]);
}
playNext(items: QueueItem[]) {
if (items.length === 0) return;
if (this.currentId === null) { if (this.currentId === null) {
this.playQueue([item], 0); this.playQueue(items, 0);
return; return;
} }
const tracks = [...this.tracks]; const tracks = [...this.tracks];
tracks.splice(this.#index() + 1, 0, toTrack(item)); tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
this.tracks = tracks; this.tracks = tracks;
} }
appendToQueue(items: QueueItem[]) {
if (items.length === 0) return;
if (this.currentId === null) {
this.playQueue(items, 0);
return;
}
this.tracks = [...this.tracks, ...items.map(toTrack)];
}
playTrack(id: string | number) { playTrack(id: string | number) {
if (this.currentId === id) { if (this.currentId === id) {
this.toggle(); this.toggle();
@@ -174,5 +174,13 @@
menu={albumMenu} menu={albumMenu}
playlists={data.playlists} playlists={data.playlists}
onClose={() => (albumMenu = null)} onClose={() => (albumMenu = null)}
onPlayNext={() => {
player.playNext(queue);
albumMenu = null;
}}
onAddToQueue={() => {
player.appendToQueue(queue);
albumMenu = null;
}}
/> />
{/if} {/if}
@@ -4,6 +4,7 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
@@ -163,14 +164,24 @@
contextMenu = contextMenuStateFor(event, target); contextMenu = contextMenuStateFor(event, target);
} }
function isForeignAlbum(album: LocalAlbum) {
return String(album.ownerUserId) !== data.user?.sub;
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) { function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
albumMenu = albumContextMenuStateFor(event, kind, albumId); albumMenu = albumContextMenuStateFor(event, kind, albumId);
} }
async function albumPlayNext() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumQueueItems(kind, albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
}
function addToQueue() { function addToQueue() {
if (!contextMenu) return; if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu)); player.addToQueue(queueItemForTarget(contextMenu));
@@ -255,9 +266,7 @@
href="/albums/{entry.album.id}" href="/albums/{entry.album.id}"
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none" class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms" style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={isForeignAlbum(entry.album) oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
? (e) => openAlbumMenu(e, 'local', entry.album.id)
: undefined}
> >
<div class="relative"> <div class="relative">
<PlaylistArt <PlaylistArt
@@ -395,5 +404,7 @@
menu={albumMenu} menu={albumMenu}
playlists={data.playlists} playlists={data.playlists}
onClose={() => (albumMenu = null)} onClose={() => (albumMenu = null)}
onPlayNext={albumPlayNext}
onAddToQueue={albumAddToQueue}
/> />
{/if} {/if}
+7 -2
View File
@@ -1,7 +1,11 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser } from '$lib/server/api'; import { createApiClient, requireUser } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import {
addAlbumToPlaylistAction,
addTrackAction,
addYouTubeToPlaylistAction
} from '$lib/server/playlistActions';
import { import {
HOME_ALBUMS_LIMIT, HOME_ALBUMS_LIMIT,
HOME_LATEST_PAGE_SIZE, HOME_LATEST_PAGE_SIZE,
@@ -62,5 +66,6 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
export const actions: Actions = { export const actions: Actions = {
addTrack: addTrackAction, addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addAlbumToPlaylist: addAlbumToPlaylistAction
}; };
+47 -4
View File
@@ -24,6 +24,11 @@
contextMenuStateFor, contextMenuStateFor,
type ContextMenuState type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import type { YouTubeSong } from '$lib/types'; import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections'; import { appendUnique } from '$lib/collections';
import { HOME_LATEST_PAGE_SIZE } from '$lib/config'; import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
@@ -33,6 +38,7 @@
let { data } = $props(); let { data } = $props();
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
let youtubeFillerItems = $state<YouTubeSong[]>([]); let youtubeFillerItems = $state<YouTubeSong[]>([]);
$effect(() => { $effect(() => {
@@ -109,6 +115,24 @@
contextMenu = null; contextMenu = null;
} }
function openAlbumMenu(event: MouseEvent, albumId: string) {
albumMenu = albumContextMenuStateFor(event, 'local', albumId);
}
async function albumPlayNext() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumQueueItems(kind, albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
}
function albumCaption(album: (typeof albums)[number]) { function albumCaption(album: (typeof albums)[number]) {
return [ return [
album.releaseYear ? String(album.releaseYear) : undefined, album.releaseYear ? String(album.releaseYear) : undefined,
@@ -195,6 +219,7 @@
: `/albums/${album.id}`} : `/albums/${album.id}`}
class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none" class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms" style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
> >
<div class="relative"> <div class="relative">
{#if album.thumbnailUrl} {#if album.thumbnailUrl}
@@ -266,9 +291,13 @@
> >
{playlist.name} {playlist.name}
</div> </div>
{#if playlist.description} <div class="mt-0.5 truncate text-sm text-muted">
<div class="mt-0.5 truncate text-sm text-muted">{playlist.description}</div> {#if playlist.description}
{/if} {playlist.description}
{:else}
{/if}
</div>
</a> </a>
{/each} {/each}
<a <a
@@ -330,7 +359,11 @@
</section> </section>
{/if} {/if}
{#if recentlyPlayed.length === 0 && albums.length === 0 && mixes.length === 0 && playlists.length === 0 && newTargets.length === 0} {#if recentlyPlayed.length === 0 && albums.length === 0 && mixes.length === 0 && playlists.length === 0 && newTargets.length === 0}
<EmptyState icon={Music} title="Todavía no hay música" description="Sube tu música o explora nuevas canciones, álbumes y listas de reproducción." /> <EmptyState
icon={Music}
title="Todavía no hay música"
description="Sube tu música o explora nuevas canciones, álbumes y listas de reproducción."
/>
{/if} {/if}
</div> </div>
@@ -387,3 +420,13 @@
onAddToQueue={addToQueue} onAddToQueue={addToQueue}
/> />
{/if} {/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
{playlists}
onClose={() => (albumMenu = null)}
onPlayNext={albumPlayNext}
onAddToQueue={albumAddToQueue}
/>
{/if}
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/albums/{albumId}/tracks', {
params: {
path: { albumId: params.id },
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
}
});
return json(unwrapOrError(result, 'No se pudo cargar el álbum.'));
};
@@ -0,0 +1,12 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/youtube/albums/{albumId}', {
params: { path: { albumId: params.albumId } }
});
return json(unwrapOrError(result, 'No se pudo cargar el álbum de YouTube.'));
};