AutoImage replaced LazyImage && general improvements to components with style props

This commit is contained in:
Eclypsed
2024-07-22 02:53:52 -04:00
parent f10a184284
commit 455a01982a
32 changed files with 883 additions and 161 deletions

503
package-lock.json generated
View File

@@ -21,6 +21,15 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/bx": "^1.1.10",
"@iconify-json/heroicons": "^1.1.22",
"@iconify-json/ic": "^1.1.17",
"@iconify-json/material-symbols": "^1.1.85",
"@iconify-json/mi": "^1.1.8",
"@iconify-json/mingcute": "^1.1.18",
"@iconify-json/ph": "^1.1.13",
"@iconify-json/solar": "^1.1.9",
"@iconify-json/teenyicons": "^1.1.9",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
@@ -34,6 +43,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"unplugin-icons": "^0.19.0",
"vite": "^5.0.3" "vite": "^5.0.3"
} }
}, },
@@ -62,6 +72,27 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@antfu/install-pkg": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.3.3.tgz",
"integrity": "sha512-nHHsk3NXQ6xkCfiRRC8Nfrg8pU5kkr3P3Y9s9dKqiuRmBD0Yap7fymNDjGFKeWhZQHqqbCS5CfeMy9wtExM24w==",
"dev": true,
"dependencies": {
"@jsdevtools/ez-spawn": "^3.0.4"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@antfu/utils": {
"version": "0.7.10",
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz",
"integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -439,6 +470,121 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@iconify-json/bx": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@iconify-json/bx/-/bx-1.1.10.tgz",
"integrity": "sha512-4JzMDYhs/hkU9mO8nNMNKZwHn706oQaD46URyUuD4fP/XAWGf01vg6wKITmQseEHsHDrtKAXjwcumfnTUDi3OQ==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/heroicons": {
"version": "1.1.22",
"resolved": "https://registry.npmjs.org/@iconify-json/heroicons/-/heroicons-1.1.22.tgz",
"integrity": "sha512-UNfSBdD/JBYBvFFhce6e3FsmeqshGz8u964R36npJvIzuORhIHUXrFerulBWFmbG0V1xvMqLZVZc4bPyjP5p7A==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/ic": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@iconify-json/ic/-/ic-1.1.17.tgz",
"integrity": "sha512-EvAjZzVESmN36zlyefylePUNaU2BQ3eRKVZ6KQSQ2bG01ppoZaiFZRri74VTyvp5Mlv2yn68ux1fgCoT+etGmA==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/material-symbols": {
"version": "1.1.85",
"resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.1.85.tgz",
"integrity": "sha512-GJXTScAIdaxxMPcp6GCd4qbntvHpG9UrF/2V03PMUuD7+1fMU5vHG5w0IGDdvqOnI9HpEcUFa7CFDVQHOpBeDA==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/mi": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@iconify-json/mi/-/mi-1.1.8.tgz",
"integrity": "sha512-DGoHbf4nkyrHNOAbbNncK1hiiYo/hk/IpdEGZ+eZJpOfWV2VypeaTaQt69pldDgQU+QDyc/GgmO4c0yIwQRfnQ==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/mingcute": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@iconify-json/mingcute/-/mingcute-1.1.18.tgz",
"integrity": "sha512-P5szgBqUMv8XVjZpbVT5eG1XtqpRPgfvZWxOR/uSqBwmVroHpxN5+nbLTHj9HJxL16msMsNbA294b69MNEQzaw==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/ph": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.1.13.tgz",
"integrity": "sha512-xtM4JJ63HCKj09WRqrBswXiHrpliBlqboWSZH8odcmqYXbvIFceU9/Til4V+MQr6+MoUC+KB72cxhky2+A6r/g==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/solar": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@iconify-json/solar/-/solar-1.1.9.tgz",
"integrity": "sha512-BcWzZqA02BiQduYizqU/J4v4RNs0MkjZUGpMbejpozH8YQSt3+S/LfV6zfVRonx/2DhXTVSqiLa1abDRAZtojQ==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/teenyicons": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@iconify-json/teenyicons/-/teenyicons-1.1.9.tgz",
"integrity": "sha512-8QsYNkeaKkUQensRE2OyTpjBmUTcHerwwTdo82Lsxh0t1g0tZVfqjuoevF5KwDFCbfObK49gdfiuyzGcmuliVw==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"dev": true
},
"node_modules/@iconify/utils": {
"version": "2.1.25",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.1.25.tgz",
"integrity": "sha512-Y+iGko8uv/Fz5bQLLJyNSZGOdMW0G7cnlEX1CiNcKsRXX9cq/y/vwxrIAtLCZhKHr3m0VJmsjVPsvnM4uX8YLg==",
"dev": true,
"dependencies": {
"@antfu/install-pkg": "^0.1.1",
"@antfu/utils": "^0.7.7",
"@iconify/types": "^2.0.0",
"debug": "^4.3.4",
"kolorist": "^1.8.0",
"local-pkg": "^0.5.0",
"mlly": "^1.6.1"
}
},
"node_modules/@iconify/utils/node_modules/@antfu/install-pkg": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.1.1.tgz",
"integrity": "sha512-LyB/8+bSfa0DFGC06zpCEfs89/XoWZwws5ygEa5D+Xsm3OfI+aXQ86VgVG7Acyef+rSZ5HE7J8rrxzrQeM3PjQ==",
"dev": true,
"dependencies": {
"execa": "^5.1.1",
"find-up": "^5.0.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -504,6 +650,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsdevtools/ez-spawn": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@jsdevtools/ez-spawn/-/ez-spawn-3.0.4.tgz",
"integrity": "sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==",
"dev": true,
"dependencies": {
"call-me-maybe": "^1.0.1",
"cross-spawn": "^7.0.3",
"string-argv": "^0.3.1",
"type-detect": "^4.0.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1205,6 +1366,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"dev": true
},
"node_modules/camelcase-css": { "node_modules/camelcase-css": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1313,6 +1480,12 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/confbox": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz",
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
"dev": true
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -1596,6 +1769,35 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true
},
"node_modules/expand-template": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1659,6 +1861,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
@@ -1774,6 +1992,18 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/getopts": { "node_modules/getopts": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
@@ -2008,6 +2238,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -2307,6 +2546,12 @@
} }
} }
}, },
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true
},
"node_modules/ky": { "node_modules/ky": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/ky/-/ky-1.4.0.tgz", "resolved": "https://registry.npmjs.org/ky/-/ky-1.4.0.tgz",
@@ -2333,12 +2578,43 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true "dev": true
}, },
"node_modules/local-pkg": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz",
"integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==",
"dev": true,
"dependencies": {
"mlly": "^1.4.2",
"pkg-types": "^1.0.3"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true "dev": true
}, },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -2403,6 +2679,12 @@
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true "dev": true
}, },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2425,6 +2707,15 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -2491,6 +2782,18 @@
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
}, },
"node_modules/mlly": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz",
"integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==",
"dev": true,
"dependencies": {
"acorn": "^8.11.3",
"pathe": "^1.1.2",
"pkg-types": "^1.1.1",
"ufo": "^1.5.3"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -2602,6 +2905,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2639,12 +2954,66 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
"dev": true "dev": true
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": { "node_modules/path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -2684,6 +3053,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/periscopic": { "node_modules/periscopic": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
@@ -2736,6 +3111,17 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/pkg-types": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.3.tgz",
"integrity": "sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==",
"dev": true,
"dependencies": {
"confbox": "^0.1.7",
"mlly": "^1.7.1",
"pathe": "^1.1.2"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.39", "version": "8.4.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
@@ -3431,6 +3817,15 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -3527,6 +3922,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -3926,6 +4330,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
@@ -3939,11 +4352,74 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/ufo": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
"dev": true
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
}, },
"node_modules/unplugin": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.11.0.tgz",
"integrity": "sha512-3r7VWZ/webh0SGgJScpWl2/MRCZK5d3ZYFcNaeci/GQ7Teop7zf0Nl2pUuz7G21BwPd9pcUPOC5KmJ2L3WgC5g==",
"dev": true,
"dependencies": {
"acorn": "^8.11.3",
"chokidar": "^3.6.0",
"webpack-sources": "^3.2.3",
"webpack-virtual-modules": "^0.6.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/unplugin-icons": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-0.19.0.tgz",
"integrity": "sha512-u5g/gIZPZEj1wUGEQxe9nzftOSqmblhusc+sL3cawIRoIt/xWpE6XYcPOfAeFTYNjSbRrX/3QiX89PFiazgU1w==",
"dev": true,
"dependencies": {
"@antfu/install-pkg": "^0.3.3",
"@antfu/utils": "^0.7.7",
"@iconify/utils": "^2.1.23",
"debug": "^4.3.4",
"kolorist": "^1.8.0",
"local-pkg": "^0.5.0",
"unplugin": "^1.10.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@svgr/core": ">=7.0.0",
"@svgx/core": "^1.0.1",
"@vue/compiler-sfc": "^3.0.2 || ^2.7.0",
"vue-template-compiler": "^2.6.12",
"vue-template-es2015-compiler": "^1.9.0"
},
"peerDependenciesMeta": {
"@svgr/core": {
"optional": true
},
"@svgx/core": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"vue-template-compiler": {
"optional": true
},
"vue-template-es2015-compiler": {
"optional": true
}
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
@@ -4070,6 +4546,21 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
}, },
"node_modules/webpack-sources": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"dev": true,
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -4202,6 +4693,18 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.23.8", "version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",

View File

@@ -10,6 +10,15 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/bx": "^1.1.10",
"@iconify-json/heroicons": "^1.1.22",
"@iconify-json/ic": "^1.1.17",
"@iconify-json/material-symbols": "^1.1.85",
"@iconify-json/mi": "^1.1.8",
"@iconify-json/mingcute": "^1.1.18",
"@iconify-json/ph": "^1.1.13",
"@iconify-json/solar": "^1.1.9",
"@iconify-json/teenyicons": "^1.1.9",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
@@ -23,6 +32,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"unplugin-icons": "^0.19.0",
"vite": "^5.0.3" "vite": "^5.0.3"
}, },
"type": "module", "type": "module",

2
src/app.d.ts vendored
View File

@@ -1,3 +1,5 @@
import 'unplugin-icons/types/svelte4.d.ts'
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {

View File

@@ -1,6 +1,6 @@
import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit' import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private' import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private'
import { userExists, mixExists } from '$lib/server/api-helper' import { userExists, connectionExists, mixExists } from '$lib/server/api-helper'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
function verifyAuthToken(event: RequestEvent) { function verifyAuthToken(event: RequestEvent) {
@@ -23,6 +23,9 @@ const handleAPIRequest: Handle = async ({ event, resolve }) => {
const userId = event.params.userId const userId = event.params.userId
if (userId && !(await userExists(userId))) return new Response(`User ${userId} not found`, { status: 404 }) if (userId && !(await userExists(userId))) return new Response(`User ${userId} not found`, { status: 404 })
const connectionId = event.params.connectionId
if (connectionId && !(await connectionExists(connectionId))) return new Response(`Connection ${connectionId} not found`, { status: 404 })
const mixId = event.params.mixId const mixId = event.params.mixId
if (mixId && !(await mixExists(mixId))) return new Response(`Mix ${mixId} not found`, { status: 404 }) if (mixId && !(await mixExists(mixId))) return new Response(`Mix ${mixId} not found`, { status: 404 })

6
src/lib/api-helper.ts Normal file
View File

@@ -0,0 +1,6 @@
import ky from 'ky'
export const apiV1 = ky.create({
prefixUrl: '/api/v1/',
credentials: 'include',
})

View File

@@ -1,39 +1,35 @@
<script lang="ts"> <script lang="ts">
import LazyImage from '$lib/components/media/lazyImage.svelte' import AutoImage from './autoImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte' import ArtistList from '$lib/components/media/artistList.svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores' import { queue, newestAlert } from '$lib/stores'
import { apiV1 } from '$lib/api-helper'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte' import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let album: Album export let album: Album
async function playAlbum() { async function playAlbum() {
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, { try {
credentials: 'include', const response = await apiV1.get(`connections/${album.connection.id}/album/${album.id}/items`).json<{ items: Song[] }>()
}).catch(() => null) $queue.setQueue(response.items)
} catch {
if (!itemsResponse || !itemsResponse.ok) {
$newestAlert = ['warning', 'Failed to play album'] $newestAlert = ['warning', 'Failed to play album']
return
} }
const data = (await itemsResponse.json()) as { items: Song[] }
$queue.setQueue(data.items)
} }
</script> </script>
<div class="overflow-hidden"> <div class="overflow-hidden">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded"> <div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}> <button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} /> <AutoImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} --object-fit="cover" --border-radius="0.25rem" --height="100%" />
</button> </button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity"> <div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity">
<IconButton halo={true} on:click={playAlbum}> <IconButton halo={true} on:click={playAlbum}>
<i slot="icon" class="fa-solid fa-play text-2xl" /> <i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton> </IconButton>
</div> </div>
<div id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity"> <div id="connection-type-icon" class="absolute left-2 top-2 h-6 w-6 opacity-0 transition-opacity">
<ServiceLogo type={album.connection.type} /> <ServiceLogo type={album.connection.type} />
</div> </div>
</div> </div>
@@ -52,9 +48,6 @@
#thumbnail-wrapper:hover > #play-button { #thumbnail-wrapper:hover > #play-button {
opacity: 1; opacity: 1;
} }
/* #connection-type-icon {
filter: grayscale();
} */
#thumbnail-wrapper:hover > #connection-type-icon { #thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1; opacity: 1;
} }

View File

@@ -0,0 +1,57 @@
<!--
@component
A component to help render images in a smooth and efficient way. The url passed will be fetched via Lazuli's
remoteImage API endpoint with size parameters that are dynamically calculated base off of the image's container's
width and height. Images are lazily loaded unless 'eager' loading is specified.
@param thumbnailUrl A string of a URL that points to the desired image.
@param alt Supplementary text in the event the image fails to load.
@param loading Optional. Either the string 'lazy' or 'eager', defaults to lazy. The method by which to load the image.
-->
<script lang="ts">
import { onMount } from 'svelte'
export let thumbnailUrl: string
export let alt: string
export let loading: 'lazy' | 'eager' = 'lazy'
let currentSlot: number = 1 // 1 | 2
let imageContainer: HTMLDivElement | undefined
let slot1: HTMLImageElement | undefined
let slot2: HTMLImageElement | undefined
const SIZE_TO_PIXEL_FACTOR = 1.5 // Images will be fetched with a pixel density 1.5x the size of its container. This is a good compromise between sharpness and performance
// ? Maybe implement auto-resizing
function updateImage(newThumbnailURL: string) {
if (!(slot1 && slot2 && imageContainer)) return
const maxWidth = imageContainer.clientWidth * SIZE_TO_PIXEL_FACTOR
const maxHeight = imageContainer.clientHeight * SIZE_TO_PIXEL_FACTOR
const imageSrc = `/api/remoteImage?url=${newThumbnailURL}&${maxWidth > maxHeight ? `maxWidth=${maxWidth}` : `maxHeight=${maxHeight}`}`
currentSlot === 1 ? (slot2.src = imageSrc) : (slot1.src = imageSrc)
}
onMount(() => updateImage(thumbnailUrl))
$: updateImage(thumbnailUrl)
</script>
<div id="image-container" bind:this={imageContainer} class="grid">
<img bind:this={slot1} {alt} {loading} class:opacity-0={currentSlot === 2} class:hidden={!slot1 || slot1.src === ''} class="h-full transition-opacity duration-500" on:load={() => (currentSlot = 1)} />
<img bind:this={slot2} {alt} {loading} class:opacity-0={currentSlot === 1} class:hidden={!slot1 || slot2.src === ''} class="h-full transition-opacity duration-500" on:load={() => (currentSlot = 2)} />
</div>
<style>
#image-container {
height: var(--height);
}
img {
grid-area: 1 / 1;
object-fit: var(--object-fit);
object-position: var(--object-position);
border-radius: var(--border-radius);
}
</style>

View File

@@ -1,70 +0,0 @@
<!--
@component
A component to help render images in a smooth and efficient way. The url passed will be fetched via Lazuli's
remoteImage API endpoint with size parameters that are dynamically calculated base off of the image's container's
width and height. Images are lazily loaded unless 'eager' loading is specified.
@param thumbnailUrl A string of a URL that points to the desired image.
@param alt Supplementary text in the event the image fails to load.
@param loadingMethod Optional. Either the string 'lazy' or 'eager', defaults to lazy. The method by which to load the image.
@param objectFit One of the following fill, contain, cover, none, scale-down. Specifies the object-fit styling of the image
@param objectPosistion Optional. Specifies the object-position styling of the image. Defaults to 'center'
-->
<script lang="ts">
import { onMount } from 'svelte'
export let thumbnailUrl: string
export let alt: string
export let loadingMethod: 'lazy' | 'eager' = 'lazy'
export let objectFit: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
export let objectPosition: string = 'center'
let imageContainer: HTMLDivElement
// TODO: Implement auto-resizing
function updateImage(newThumbnailURL: string) {
if (!imageContainer) return
const width = imageContainer.clientWidth * 1.5 // 1.5x is a good compromise between sharpness and performance
const height = imageContainer.clientHeight * 1.5
const newImage = new Image(width, height)
imageContainer.appendChild(newImage)
newImage.loading = loadingMethod
newImage.src = `/api/remoteImage?url=${newThumbnailURL}&`.concat(width > height ? `maxWidth=${width}` : `maxHeight=${height}`)
newImage.alt = alt
newImage.style.width = '100%'
newImage.style.height = '100%'
newImage.style.objectFit = objectFit
newImage.style.objectPosition = objectPosition
newImage.style.opacity = '0'
newImage.style.position = 'absolute'
function removeOldImage() {
if (imageContainer.childElementCount > 1) {
const oldImage = imageContainer.firstChild! as HTMLImageElement
oldImage.style.opacity = '0'
setTimeout(() => imageContainer.removeChild(oldImage), 500)
}
}
newImage.onload = () => {
removeOldImage()
newImage.style.transition = 'opacity 500ms ease'
newImage.style.opacity = '1'
}
newImage.onerror = () => {
console.error(`Image from url: ${newThumbnailURL} failed to update`)
imageContainer.removeChild(newImage)
}
}
onMount(() => updateImage(thumbnailUrl))
$: updateImage(thumbnailUrl)
</script>
<div bind:this={imageContainer} class="relative h-full w-full"></div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import LazyImage from './lazyImage.svelte' import AutoImage from './autoImage.svelte'
import ArtistList from './artistList.svelte' import ArtistList from './artistList.svelte'
export let mediaItem: Song | Album | Artist | Playlist export let mediaItem: Song | Album | Artist | Playlist
@@ -12,7 +12,7 @@
<div id="list-item" class="h-16 w-full"> <div id="list-item" class="h-16 w-full">
<div class="h-full overflow-clip rounded-md"> <div class="h-full overflow-clip rounded-md">
{#if thumbnailUrl} {#if thumbnailUrl}
<LazyImage {thumbnailUrl} alt={`${mediaItem.name} thumbnial`} objectFit={'cover'} /> <AutoImage {thumbnailUrl} alt="{mediaItem.name} jacket" --object-fit="cover" />
{:else} {:else}
<div id="thumbnail-placeholder" class="grid h-full w-full place-items-center bg-lazuli-primary"> <div id="thumbnail-placeholder" class="grid h-full w-full place-items-center bg-lazuli-primary">
<i class="fa-solid {mediaItem.type === 'artist' ? 'fa-user' : 'fa-play'} text-2xl" /> <i class="fa-solid {mediaItem.type === 'artist' ? 'fa-user' : 'fa-play'} text-2xl" />

View File

@@ -4,8 +4,16 @@
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import Slider from '$lib/components/util/slider.svelte' import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import AutoImage from '$lib/components/media/autoImage.svelte'
import PhQueueBold from '~icons/ph/queue-bold'
import PhShuffleBold from '~icons/ph/shuffle-bold'
import PhRepeatBold from '~icons/ph/repeat-bold'
import BxVolumeFull from '~icons/bx/volume-full'
import BxVolumeLow from '~icons/bx/volume-low'
import BxVolume from '~icons/bx/volume'
import MiExpand from '~icons/mi/expand'
import { onMount, createEventDispatcher } from 'svelte' import { onMount, createEventDispatcher } from 'svelte'
import { slide } from 'svelte/transition' import { slide, fade } from 'svelte/transition'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@@ -76,15 +84,10 @@
let playerWidth: number let playerWidth: number
</script> </script>
<div <div bind:clientWidth={playerWidth} transition:slide id="player" class="flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2.5 text-neutral-400">
bind:clientWidth={playerWidth} <div id="details" class="flex h-full items-center gap-3 overflow-clip py-2.5" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 16rem">
transition:slide
id="player"
class="fixed {playerWidth > 800 ? 'bottom-0 left-0 right-0' : 'bottom-3 left-3 right-3 rounded-lg'} flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2 text-neutral-400 transition-all"
>
<div id="details" class="flex h-full items-center gap-3 overflow-clip py-2" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 18rem">
<div class="relative aspect-square h-full"> <div class="relative aspect-square h-full">
<img src="/api/remoteImage?url={mediaItem.thumbnailUrl}&maxHeight=96" alt="jacket" class="h-full w-full rounded object-cover" /> <AutoImage thumbnailUrl={mediaItem.thumbnailUrl} alt="{mediaItem.name} jacket" loading="eager" --object-fit="cover" --height="100%" --border-radius="0.25rem" />
<div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50"> <div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
<IconButton on:click={() => (paused = !paused)}> <IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" /> <i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
@@ -93,15 +96,15 @@
</div> </div>
<div class="flex flex-col justify-center gap-1"> <div class="flex flex-col justify-center gap-1">
<ScrollingText> <ScrollingText>
<span slot="text" class="line-clamp-1 text-sm font-semibold text-neutral-200">{mediaItem.name}</span> <span slot="text" title={mediaItem.name} class="line-clamp-1 text-sm font-medium text-neutral-200">{mediaItem.name}</span>
</ScrollingText> </ScrollingText>
<div class="line-clamp-1 text-xs"> <div class="line-clamp-1 text-xs">
<ArtistList {mediaItem} /> <ArtistList {mediaItem} />
</div> </div>
</div> </div>
<div class="h-8"> <div class="h-8">
<IconButton color={'#ec4899'} on:click={() => (favorite = !favorite)}> <IconButton --color="#ec4899" toggled={favorite} on:click={() => (favorite = !favorite)}>
<i slot="icon" class={favorite ? 'fa-solid fa-heart text-pink-500' : 'fa-regular fa-heart'} /> <i slot="icon" class={favorite ? 'fa-solid fa-heart' : 'fa-regular fa-heart'} />
</IconButton> </IconButton>
</div> </div>
<span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span> <span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span>
@@ -150,25 +153,36 @@
{#if playerWidth > 450} {#if playerWidth > 450}
<div id="tools" class="flex h-full justify-end gap-0.5 py-6" style="backgrounds: linear-gradient(to right, purple, orange);"> <div id="tools" class="flex h-full justify-end gap-0.5 py-6" style="backgrounds: linear-gradient(to right, purple, orange);">
{#if playerWidth > 1100} {#if playerWidth > 1100}
<IconButton on:click={() => dispatch('toggleShuffle')}> <IconButton --hover-color="#e5e5e5" toggled={shuffled} on:click={() => dispatch('toggleShuffle')}>
<i slot="icon" class:text-lazuli-primary={shuffled} class="fa-solid fa-shuffle" /> <PhShuffleBold slot="icon" />
</IconButton> </IconButton>
<IconButton on:click={() => (loop = !loop)}> <IconButton --hover-color="#e5e5e5" toggled={loop} on:click={() => (loop = !loop)}>
<i slot="icon" class:text-lazuli-primary={loop} class="fa-solid fa-repeat" /> <PhRepeatBold slot="icon" />
</IconButton>
<IconButton --hover-color="#e5e5e5">
<PhQueueBold slot="icon" />
</IconButton> </IconButton>
<div class="flex h-full items-center gap-1"> <div class="flex h-full items-center gap-1">
<IconButton on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}> <IconButton --hover-color="#e5e5e5" on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}>
<i slot="icon" class="fa-solid {volume > MAX_VOLUME / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" /> <span slot="icon" class="relative grid place-items-center">
{#if volume > MAX_VOLUME / 2}
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolumeFull /></span>
{:else if volume > 0}
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolumeLow /></span>
{:else}
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolume /></span>
{/if}
</span>
</IconButton> </IconButton>
<div class="mr-2 w-20"> <div class="mr-2 w-20">
<Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} /> <Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
</div> </div>
</div> </div>
<IconButton> <IconButton --hover-color="#e5e5e5">
<i slot="icon" class="fa-solid fa-up-right-and-down-left-from-center" /> <MiExpand slot="icon" />
</IconButton> </IconButton>
{/if} {/if}
<IconButton> <IconButton --hover-color="#e5e5e5">
<i slot="icon" class="fa-solid fa-ellipsis-vertical" /> <i slot="icon" class="fa-solid fa-ellipsis-vertical" />
</IconButton> </IconButton>
</div> </div>
@@ -189,3 +203,13 @@
on:error={() => setTimeout(() => audioElement.load(), 5000)} on:error={() => setTimeout(() => audioElement.load(), 5000)}
/> />
</div> </div>
<style>
#player {
border-radius: var(--border-radius);
transition: border-radius 150ms linear;
-webkit-box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
-moz-box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
}
</style>

View File

@@ -4,9 +4,9 @@
import { queue } from '$lib/stores' import { queue } from '$lib/stores'
import Services from '$lib/services.json' import Services from '$lib/services.json'
// import { FastAverageColor } from 'fast-average-color' // import { FastAverageColor } from 'fast-average-color'
import AutoImage from './autoImage.svelte'
import Slider from '$lib/components/util/slider.svelte' import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import LazyImage from './lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import ScrollingText from '$lib/components/util/scrollingText.svelte' import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte' import ArtistList from './artistList.svelte'
@@ -109,7 +109,7 @@
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10"> <main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
<section class="flex w-96 min-w-64 gap-2"> <section class="flex w-96 min-w-64 gap-2">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl p-2"> <div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl p-2">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} /> <AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="{currentlyPlaying.name} jacket" --object-fit="cover" />
</div> </div>
<section class="flex flex-grow flex-col justify-center gap-1"> <section class="flex flex-grow flex-col justify-center gap-1">
<div class="h-6"> <div class="h-6">
@@ -185,7 +185,7 @@
{:else} {:else}
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full"> <main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
<div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]"> <div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} /> <AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="" --object-fit="cover" />
</div> </div>
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16"> <section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
<div class="grid grid-cols-[2fr_1fr]"> <div class="grid grid-cols-[2fr_1fr]">
@@ -202,7 +202,7 @@
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong> <strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 overflow-clip rounded-lg border border-neutral-300 bg-neutral-900 pr-3"> <div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 overflow-clip rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
<div class="aspect-square h-full"> <div class="aspect-square h-full">
<LazyImage thumbnailUrl={next.thumbnailUrl} alt={`${next.name} jacket`} objectFit={'cover'} /> <AutoImage thumbnailUrl={next.thumbnailUrl} alt="{next.name} jacket" --object-fit="cover" />
</div> </div>
<div> <div>
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div> <div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
@@ -214,7 +214,7 @@
{/if} {/if}
</section> </section>
</div> </div>
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} /> <AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="{currentlyPlaying.name} jacket" --object-fit="contain" --object-position="left" />
</section> </section>
<section class="self-center px-16"> <section class="self-center px-16">
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light"> <div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import AutoImage from './autoImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores'
import { apiV1 } from '$lib/api-helper'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let playlist: Playlist
async function playPlaylist() {
try {
const initialResponse = await apiV1.get(`connections/${playlist.connection.id}/playlist/${playlist.id}/items?startIndex=0&limit=50`).json<{ items: Song[] }>()
$queue.setQueue(initialResponse.items)
if (initialResponse.items.length < 50) return
const remainderResponse = await apiV1.get(`connections/${playlist.connection.id}/playlist/${playlist.id}/items?startIndex=50`).json<{ items: Song[] }>()
$queue.enqueue(...remainderResponse.items)
} catch {
$newestAlert = ['warning', 'Failed to play playlist']
}
}
</script>
<div class="overflow-hidden">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/playlist?id=${playlist.id}&connection=${playlist.connection.id}`)}>
<AutoImage thumbnailUrl={playlist.thumbnailUrl} alt="{playlist.name} jacket" --object-fit="cover" --height="100%" --border-radius="0.25rem" />
</button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity">
<IconButton halo={true} on:click={playPlaylist}>
<i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton>
</div>
<div id="connection-type-icon" class="absolute left-2 top-2 h-6 w-6 opacity-0 transition-opacity">
<ServiceLogo type={playlist.connection.type} />
</div>
</div>
<div class="py-2 text-center text-sm">
<div class="line-clamp-2">{playlist.name}</div>
<div class="line-clamp-2 text-neutral-400">
<ArtistList mediaItem={playlist} />
</div>
</div>
</div>
<style>
#thumbnail-wrapper:hover > #thumbnail {
filter: brightness(35%);
}
#thumbnail-wrapper:hover > #play-button {
opacity: 1;
}
#thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1;
}
#thumbnail {
transition: filter 150ms ease;
}
</style>

View File

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
export let toggled = false
export let disabled = false export let disabled = false
export let halo = false export let halo = false
export let color = 'var(--lazuli-primary)'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
@@ -9,10 +9,10 @@
</script> </script>
<button <button
class:toggled
class:disabled class:disabled
class:halo class:halo
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90" class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
style="--button-color: {color}"
on:click|preventDefault|stopPropagation={() => dispatch('click')} on:click|preventDefault|stopPropagation={() => dispatch('click')}
{disabled} {disabled}
> >
@@ -27,7 +27,7 @@
content: ''; content: '';
width: 0; width: 0;
height: 0; height: 0;
background-color: color-mix(in srgb, var(--button-color) 20%, transparent); background-color: color-mix(in srgb, var(--color, var(--lazuli-primary)) 20%, transparent);
border-radius: 100%; border-radius: 100%;
transition-property: width height; transition-property: width height;
transition-duration: 200ms; transition-duration: 200ms;
@@ -40,7 +40,10 @@
button :global(> :first-child) { button :global(> :first-child) {
transition: color 200ms; transition: color 200ms;
} }
button:not(.disabled):hover :global(> :first-child) { button:not(.disabled).toggled :global(> :first-child) {
color: var(--button-color); color: var(--color, var(--lazuli-primary));
}
button:not(.disabled):not(.toggled):hover :global(> :first-child) {
color: var(--hover-color, var(--color, var(--lazuli-primary)));
} }
</style> </style>

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import IconButton from './iconButton.svelte' import IconButton from './iconButton.svelte'
import MingcuteMenuLine from '~icons/mingcute/menu-line'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let searchBar: HTMLElement, searchInput: HTMLInputElement, searchBarWidth: number let searchInput: HTMLInputElement, searchBarWidth: number
const HIDE_SEARCHBAR_BREAKPOINT_PX = 300 const HIDE_SEARCHBAR_BREAKPOINT_PX = 300
$: showSearchbar = searchBarWidth > HIDE_SEARCHBAR_BREAKPOINT_PX $: showSearchbar = searchBarWidth > HIDE_SEARCHBAR_BREAKPOINT_PX
@@ -25,7 +26,7 @@
<div class="mr-4 flex h-full items-center"> <div class="mr-4 flex h-full items-center">
<div class="h-full p-1"> <div class="h-full p-1">
<IconButton halo={true} on:click={() => dispatch('opensidebar')}> <IconButton halo={true} on:click={() => dispatch('opensidebar')}>
<i slot="icon" class="fa-solid fa-bars text-xl" /> <MingcuteMenuLine slot="icon" class="text-lg" />
</IconButton> </IconButton>
</div> </div>
<!-- --------------This is a placeholder image-------------- --> <!-- --------------This is a placeholder image-------------- -->
@@ -35,7 +36,6 @@
{#if showSearchbar} {#if showSearchbar}
<search <search
role="search" role="search"
bind:this={searchBar}
class="relative flex h-full w-full max-w-xl items-center gap-2.5 rounded-lg border border-[rgba(255,255,255,0.1)] px-4 py-2 text-neutral-400" class="relative flex h-full w-full max-w-xl items-center gap-2.5 rounded-lg border border-[rgba(255,255,255,0.1)] px-4 py-2 text-neutral-400"
style="background-color: rgba(255,255,255, 0.07);" style="background-color: rgba(255,255,255, 0.07);"
> >

View File

@@ -13,7 +13,7 @@
let slidingTextWidth: number, slidingTextWrapperWidth: number let slidingTextWidth: number, slidingTextWrapperWidth: number
let scrollDirection: 1 | -1 = 1 let scrollDirection: 1 | -1 = 1
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth $: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 40}s` $: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 30}s`
</script> </script>
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-full w-full overflow-clip"> <div bind:clientWidth={slidingTextWrapperWidth} class="relative h-full w-full overflow-clip">

View File

@@ -3,16 +3,20 @@
import { sineOut } from 'svelte/easing' import { sineOut } from 'svelte/easing'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import IconButton from './iconButton.svelte' import IconButton from './iconButton.svelte'
import PhPlaylistBold from '~icons/ph/playlist-bold'
import MaterialSymbolsHome from '~icons/material-symbols/home'
import IcSharpVideoLibrary from '~icons/ic/sharp-video-library'
type NavButton = { type NavButton = {
name: string name: string
path: string path: string
icon: string icon: any
} }
const navButtons: NavButton[] = [ const navButtons: NavButton[] = [
{ name: 'Home', path: '/', icon: 'fa-solid fa-house' }, { name: 'Home', path: '/', icon: MaterialSymbolsHome },
{ name: 'Library', path: '/library', icon: 'fa-solid fa-book' }, { name: 'Mixes', path: '/mixes', icon: PhPlaylistBold },
{ name: 'Library', path: '/library', icon: IcSharpVideoLibrary },
] ]
const OPEN_CLOSE_DURATION = 250 const OPEN_CLOSE_DURATION = 250
@@ -41,7 +45,7 @@
}} }}
class="flex w-full items-center gap-6 px-10 py-3.5 text-left transition-colors hover:bg-[rgba(255,255,255,0.1)]" class="flex w-full items-center gap-6 px-10 py-3.5 text-left transition-colors hover:bg-[rgba(255,255,255,0.1)]"
> >
<i class={tab.icon} /> <svelte:component this={tab.icon} class="text-lg" />
{tab.name} {tab.name}
</button> </button>
{/each} {/each}

View File

@@ -6,7 +6,11 @@ export async function userExists(userId: string): Promise<boolean> {
return Boolean(await DB.users.where('id', userId).first(DB.db.raw('EXISTS(SELECT 1)'))) return Boolean(await DB.users.where('id', userId).first(DB.db.raw('EXISTS(SELECT 1)')))
} }
export async function mixExists(mixId: string): Promise<Boolean> { export async function connectionExists(connectionId: string): Promise<boolean> {
return Boolean(await DB.connections.where('id', connectionId).first(DB.db.raw('EXISTS(SELECT 1)')))
}
export async function mixExists(mixId: string): Promise<boolean> {
return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)'))) return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)')))
} }

View File

@@ -570,6 +570,7 @@ export class YouTubeMusic implements Connection {
*/ */
public async getSongs(ids: Iterable<string>): Promise<Song[]> { public async getSongs(ids: Iterable<string>): Promise<Song[]> {
const uniqueIds = new Set(ids) const uniqueIds = new Set(ids)
if (uniqueIds.size === 0) return []
const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json<InnerTube.Queue.Response>() const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json<InnerTube.Queue.Response>()

View File

@@ -1,12 +0,0 @@
export const generateUUID = (): string => {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
}
export const getDeviceUUID = (): string => {
const existingUUID = localStorage.getItem('deviceUUID')
if (existingUUID) return existingUUID
const newUUID = generateUUID()
localStorage.setItem('deviceUUID', newUUID)
return newUUID
}

View File

@@ -9,25 +9,32 @@
$: currentlyPlaying = $queue.current $: currentlyPlaying = $queue.current
$: shuffled = $queue.isShuffled $: shuffled = $queue.isShuffled
let playerWidth: number
</script> </script>
<main id="grid-wrapper" class="h-full"> <main id="grid-wrapper" class="relative h-full">
<Navbar on:opensidebar={sidebar.open} /> <Navbar on:opensidebar={sidebar.open} />
<Sidebar bind:this={sidebar} /> <Sidebar bind:this={sidebar} />
<section id="content-wrapper" class="overflow-y-scroll no-scrollbar"> <section id="content-wrapper" class="overflow-y-scroll no-scrollbar">
<div class="my-8">
<slot /> <slot />
</section> </div>
{#if currentlyPlaying} {#if currentlyPlaying}
<div bind:clientWidth={playerWidth} class="sticky {playerWidth > 800 ? 'bottom-0' : 'bottom-3 mx-3'} transition-all">
<MediaPlayer <MediaPlayer
mediaItem={currentlyPlaying} mediaItem={currentlyPlaying}
{shuffled} {shuffled}
mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null} mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null}
--border-radius={playerWidth > 800 ? '0' : '0.5rem'}
on:stop={() => $queue.clear()} on:stop={() => $queue.clear()}
on:next={() => $queue.next()} on:next={() => $queue.next()}
on:previous={() => $queue.previous()} on:previous={() => $queue.previous()}
on:toggleShuffle={() => $queue.toggleShuffle()} on:toggleShuffle={() => $queue.toggleShuffle()}
/> />
</div>
{/if} {/if}
</section>
</main> </main>
<style> <style>
@@ -35,4 +42,8 @@
display: grid; display: grid;
grid-template-rows: min-content auto; grid-template-rows: min-content auto;
} }
#content-wrapper {
display: grid;
grid-template-rows: auto min-content;
}
</style> </style>

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types' import type { PageData } from './$types'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import MediaCard from '$lib/components/media/mediaCard.svelte'
import AlbumCard from '$lib/components/media/albumCard.svelte' import AlbumCard from '$lib/components/media/albumCard.svelte'
import PlaylistCard from '$lib/components/media/playlistCard.svelte'
export let data: PageData export let data: PageData
</script> </script>
@@ -11,9 +11,13 @@
{#await data.recommendations} {#await data.recommendations}
<Loader /> <Loader />
{:then recommendations} {:then recommendations}
<div id="card-wrapper" class="grid w-full gap-4 justify-self-center px-[5%] pt-8"> <div id="card-wrapper" class="grid w-full gap-4 justify-self-center px-[5%]">
{#each recommendations.filter((item) => item.type === 'album') as album} {#each recommendations as mediaItem}
<AlbumCard {album} /> {#if mediaItem.type === 'album'}
<AlbumCard album={mediaItem} />
{:else if mediaItem.type === 'playlist'}
<PlaylistCard playlist={mediaItem} />
{/if}
{/each} {/each}
</div> </div>
{/await} {/await}

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import IconButton from '$lib/components/util/iconButton.svelte'
import IcBaselinePlus from '~icons/ic/baseline-plus'
</script>
<main class="flex flex-wrap justify-center">
<div class="grid aspect-square h-56 place-items-center">
<div class="aspect-square h-16">
<IconButton halo={true}>
<IcBaselinePlus slot="icon" class="text-4xl text-neutral-300" />
</IconButton>
</div>
</div>
</main>

View File

@@ -7,7 +7,6 @@
import { newestAlert } from '$lib/stores.js' import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js' import type { PageServerData } from './$types.js'
import type { SubmitFunction } from '@sveltejs/kit' import type { SubmitFunction } from '@sveltejs/kit'
import { getDeviceUUID } from '$lib/utils'
import { SvelteComponent, type ComponentType } from 'svelte' import { SvelteComponent, type ComponentType } from 'svelte'
import ConnectionProfile from './connectionProfile.svelte' import ConnectionProfile from './connectionProfile.svelte'
import { enhance } from '$app/forms' import { enhance } from '$app/forms'
@@ -21,6 +20,15 @@
data.connections.then((userConnections) => ('error' in userConnections ? (errorMessage = userConnections.error) : (connections = userConnections))) data.connections.then((userConnections) => ('error' in userConnections ? (errorMessage = userConnections.error) : (connections = userConnections)))
function getDeviceUUID(): string {
const existingUUID = localStorage.getItem('deviceUUID')
if (existingUUID) return existingUUID
const newUUID = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
localStorage.setItem('deviceUUID', newUUID)
return newUUID
}
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
const { serverUrl, username, password } = Object.fromEntries(formData) const { serverUrl, username, password } = Object.fromEntries(formData)

View File

@@ -64,12 +64,12 @@ function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?
switch (imageURL.origin) { switch (imageURL.origin) {
case 'https://i.ytimg.com': case 'https://i.ytimg.com':
case 'https://www.gstatic.com': case 'https://www.gstatic.com':
// These two origins correspond to images that can't have their size modified with search params, so we just return them at the default res case 'https://music.youtube.com':
// These origins correspond to images that can't have their size modified with search params, so we just return them at the default res
return baseURL return baseURL
case 'https://lh3.googleusercontent.com': case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com': case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com': case 'https://yt3.ggpht.com':
case 'https://music.youtube.com':
const fakeQueryParams = [] const fakeQueryParams = []
if (maxWidth) fakeQueryParams.push(`w${Math.min(maxWidth, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`) if (maxWidth) fakeQueryParams.push(`w${Math.min(maxWidth, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`)
if (maxHeight) fakeQueryParams.push(`h${Math.min(maxHeight, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`) if (maxHeight) fakeQueryParams.push(`h${Math.min(maxHeight, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`)

View File

@@ -0,0 +1,19 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections = (await Promise.all(ids.map((id) => ConnectionFactory.getConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
const getConnectionInfo = (connection: Connection) =>
connection.getConnectionInfo().catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return null
})
const connectionInfo = (await Promise.all(connections.map(getConnectionInfo))).filter((connectionInfo): connectionInfo is ConnectionInfo => connectionInfo !== null)
return Response.json({ connections: connectionInfo })
}

View File

@@ -0,0 +1,14 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => {
const connection = await ConnectionFactory.getConnection(params.connectionId!)
const albumId = url.searchParams.get('id')
if (!albumId) return new Response(`Missing id search parameter`, { status: 400 })
const album = await connection.getAlbum(albumId).catch(() => undefined)
if (!album) return new Response(`Failed to fetch album with id: ${albumId}`, { status: 400 })
return Response.json({ album })
}

View File

@@ -0,0 +1,11 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => {
const connection = await ConnectionFactory.getConnection(params.connectionId!)
const items = await connection.getAlbumItems(params.albumId!).catch(() => null)
if (!items) return new Response(`Failed to fetch album with id: ${params.albumId!}`, { status: 400 })
return Response.json({ items })
}

View File

@@ -0,0 +1,19 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => {
const connection = await ConnectionFactory.getConnection(params.connectionId!)
const playlistId = url.searchParams.get('id')
if (!playlistId) return new Response(`Missing id search parameter`, { status: 400 })
const response = await connection
.getPlaylistItems(playlistId)
.then((playlist) => Response.json({ playlist }))
.catch((error: TypeError | Error) => {
if (error instanceof TypeError) return new Response('Bad Request', { status: 400 })
return new Response('Failed to fetch playlist items', { status: 502 })
})
return response
}

View File

@@ -0,0 +1,25 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => {
const connection = await ConnectionFactory.getConnection(params.connectionId!)
const startIndexString = url.searchParams.get('startIndex')
const limitString = url.searchParams.get('limit')
const numberStartIndex = Number(startIndexString)
const numberLimit = Number(limitString)
const startIndex = Number.isInteger(numberStartIndex) && numberStartIndex > 0 ? numberStartIndex : undefined
const limit = Number.isInteger(numberLimit) && numberLimit > 0 ? numberLimit : undefined
const response = await connection
.getPlaylistItems(params.playlistId!, { startIndex, limit })
.then((items) => Response.json({ items }))
.catch((error: TypeError | Error) => {
if (error instanceof TypeError) return new Response('Bad Request', { status: 400 })
return new Response('Failed to fetch playlist items', { status: 502 })
})
return response
}

View File

@@ -1,6 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite' import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import Icons from 'unplugin-icons/vite'
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [
sveltekit(),
Icons({
compiler: 'svelte',
autoInstall: true,
}),
],
}) })