feat: add Atmos support, use new API endpoint, streamline API caching

This commit is contained in:
binimum 2026-03-22 20:08:30 +00:00 committed by GitHub
parent 152f1ac99b
commit d783642401
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 631 additions and 173 deletions

View file

@ -18,7 +18,6 @@
"butterchurn-presets": "^2.4.7",
"client-zip": "^2.5.0",
"cookie-session": "^2.1.1",
"dashjs": "https://github.com/Dash-Industry-Forum/dash.js/archive/refs/tags/v5.1.1.tar.gz",
"eventemitter3": "^5.0.4",
"fuse.js": "^7.1.0",
"hls.js": "^1.6.15",
@ -27,6 +26,7 @@
"mime": "^4.1.0",
"npm": "^11.11.1",
"pocketbase": "^0.26.8",
"shaka-player": "^5.0.7",
"simple-icons": "^16.12.0",
"svgo": "^4.0.1",
"url-toolkit": "^2.2.5",
@ -520,26 +520,6 @@
"@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="],
"@svta/cml-608": ["@svta/cml-608@1.0.1", "", {}, "sha512-Y/Ier9VPUSOBnf0bJqdDyTlPrt4dDB+jk5mYHa1bnD2kcRl8qn7KkW3PRuj4w1aVN+BS2eHmsLxodt7P2hylUg=="],
"@svta/cml-cmcd": ["@svta/cml-cmcd@1.0.1", "", { "peerDependencies": { "@svta/cml-cta": "1.0.1", "@svta/cml-structured-field-values": "1.0.1", "@svta/cml-utils": "1.0.1" } }, "sha512-eox305g+QUJgXqOLVrbgxeQHCgl90ewwQ9O2bIoo7m+hanR8Xswu5CknFnT5qqIbLOHfw80ug+raycoAFHTQ+w=="],
"@svta/cml-cmsd": ["@svta/cml-cmsd@1.0.1", "", { "peerDependencies": { "@svta/cml-cta": "1.0.1", "@svta/cml-structured-field-values": "1.0.1", "@svta/cml-utils": "1.0.1" } }, "sha512-+nIB8PuSfb/qw+xGaArPhNqPm84tBJUbe3H1DnPL5QUsjSUI7mUIUQwAtRV1ZdEu0+80g9i0op79woB0OIwr/g=="],
"@svta/cml-cta": ["@svta/cml-cta@1.0.1", "", { "peerDependencies": { "@svta/cml-structured-field-values": "1.0.1", "@svta/cml-utils": "1.0.1" } }, "sha512-jcXqNIPv26bmFxIOFh8/c3+6WLH4qBjKpq9qTQcggDPoHuV1YBydMsJLOnYPDeK8rNMKcAkFLbnDRvyJthu5yw=="],
"@svta/cml-dash": ["@svta/cml-dash@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.0.1" } }, "sha512-lYnD1I7FUbbQND+xICI+kcRaRXuT+whKk27R8m8me5VMVu2sMsAMc7Yui6l9sxw2cBKt8pSETPYRm/1+n4LZkw=="],
"@svta/cml-id3": ["@svta/cml-id3@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.0.1" } }, "sha512-90fGlL1qRI88CcaB89k6NG6cC3kky4Eu2jwqU4HefqK+S5k2OASUxf8JXkGz+DsdaiY7sh51vGPYdolfBZS7ug=="],
"@svta/cml-request": ["@svta/cml-request@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.0.1", "@svta/cml-xml": "1.0.1" } }, "sha512-enL19BuXUjFkDDDF9jdNwUclMNPRsagnjGAetVC7xcmpDMpEx+ZLgsDip6BFNg5p6izSEk/OyujTWW1r8bDNiA=="],
"@svta/cml-structured-field-values": ["@svta/cml-structured-field-values@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.0.1" } }, "sha512-Kibciki59Pon3Pn/sl5uyrbJcSpZQDKqdCfDrokBvOdLoqqcd0oFrkEPsZBiuuIODX1CB80612xe8hopeFDyBA=="],
"@svta/cml-utils": ["@svta/cml-utils@1.0.1", "", {}, "sha512-kso3curTJfp00I1mKFoBliBApjn4aPE+wF8cPucf7TrSDVWZDeLLuF14ASmUE9m7rnrqTTK4878VvmXaXcCCfQ=="],
"@svta/cml-xml": ["@svta/cml-xml@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.0.1" } }, "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g=="],
"@svta/common-media-library": ["@svta/common-media-library@0.18.1", "", {}, "sha512-VMj1jI8OWphurcozF+dezABUm9Mht6iAsSiKsFUKVT35fddOowvLoGz23Gx6lEHaAHkDy9o/aVi5s9DSp3K15Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@ -600,12 +580,6 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg=="],
"bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="],
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
"bcp-47-normalize": ["bcp-47-normalize@2.3.0", "", { "dependencies": { "bcp-47": "^2.0.0", "bcp-47-match": "^2.0.0" } }, "sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@ -644,8 +618,6 @@
"client-zip": ["client-zip@2.5.0", "", {}, "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ=="],
"codem-isoboxer": ["codem-isoboxer@0.3.10", "", {}, "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@ -692,8 +664,6 @@
"d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="],
"dashjs": ["dashjs@https://github.com/Dash-Industry-Forum/dash.js/archive/refs/tags/v5.1.1.tar.gz", { "dependencies": { "@svta/cml-608": "1.0.1", "@svta/cml-cmcd": "1.0.1", "@svta/cml-cmsd": "1.0.1", "@svta/cml-dash": "1.0.1", "@svta/cml-id3": "1.0.1", "@svta/cml-request": "1.0.1", "@svta/cml-xml": "1.0.1", "bcp-47-match": "^2.0.3", "bcp-47-normalize": "^2.3.0", "codem-isoboxer": "0.3.10", "fast-deep-equal": "3.1.3", "html-entities": "^2.5.2", "imsc": "^1.1.5", "localforage": "^1.10.0", "path-browserify": "^1.0.1", "ua-parser-js": "^1.0.37" } }, "sha512-lhD1tvEe4PO6t086flm6WfO2Jt1EOIolDQ17F3vLomMthaL1RH96h8peIQTvrDvfSJTRXeisL+CwPj4oud5e9g=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
@ -904,8 +874,6 @@
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
"html-tags": ["html-tags@3.3.1", "", {}, "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ=="],
"htmlhint": ["htmlhint@1.9.2", "", { "dependencies": { "async": "3.2.6", "chalk": "4.1.2", "commander": "11.1.0", "glob": "^13.0.6", "is-glob": "^4.0.3", "node-sarif-builder": "3.2.0", "strip-json-comments": "3.1.1", "xml": "1.0.1" }, "bin": { "htmlhint": "bin/htmlhint" } }, "sha512-PweWSPA1Pb+AVFIOSpIGu5KhLdmtk/uf/0CpjvrDf6XUWmdTyqUljlylwSxQ0AWLvPGcBxK2n8uISsI4lCOkBQ=="],
@ -914,12 +882,8 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imsc": ["imsc@1.1.5", "", { "dependencies": { "sax": "1.2.1" } }, "sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@ -932,10 +896,6 @@
"ip-regex": ["ip-regex@4.3.0", "", {}, "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
@ -954,8 +914,6 @@
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
@ -1062,8 +1020,6 @@
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lit": ["lit@3.3.2", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ=="],
@ -1072,8 +1028,6 @@
"lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="],
"localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
@ -1156,8 +1110,6 @@
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
@ -1270,6 +1222,8 @@
"set-value": ["set-value@4.1.0", "", { "dependencies": { "is-plain-object": "^2.0.4", "is-primitive": "^3.0.1" } }, "sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw=="],
"shaka-player": ["shaka-player@5.0.7", "", {}, "sha512-dUcGDac2yRJsgLSwdYqkaOLu9X8Ih+skE5tC4odUuKtNVRCLp/FaKfXydcusRcnkHWhGZN8rG8+HTJQJs7HN1g=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@ -1382,8 +1336,6 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
@ -1574,8 +1526,6 @@
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"imsc/sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
"make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

1
images/atmos.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 194" width="280" height="194"> <path d="m 279.08361,193.27627 h -28.13515 c -53.82377,0 -96.63813,-44.03764 -96.63813,-96.638137 C 154.31033,44.037629 198.34795,0 250.94846,0 h 28.13515 z" style="fill:currentColor;stroke:none"/> <path d="m 0,0 h 28.135154 c 53.82378,0 96.638146,44.037629 96.638146,96.638133 0,52.600497 -44.037631,96.638137 -96.638146,96.638137 H 0 Z" style="fill:currentColor;stroke:none"/></svg>

After

Width:  |  Height:  |  Size: 474 B

View file

@ -3705,10 +3705,11 @@
<div class="setting-item">
<div class="info">
<span class="label">Streaming Quality</span>
<span class="description">Quality for streaming playback</span>
<span class="description">Default playback quality for streams</span>
</div>
<select id="streaming-quality-setting">
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
<option value="auto">Auto (Adaptive)</option>
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (up to 24-bit/192kHz)</option>
<option value="LOSSLESS">Lossless (16-bit)</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>
@ -4164,6 +4165,7 @@
<span class="description">Quality for track downloads</span>
</div>
<select id="download-quality-setting">
<option value="DOLBY_ATMOS">Dolby Atmos (MP4)</option>
<option value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit)</option>
<option value="LOSSLESS">Lossless (16-bit)</option>
<option value="HIGH">AAC 320kbps</option>

View file

@ -514,6 +514,7 @@ export class HiFiClient {
async search(
options: {
q?: string;
s?: string;
a?: string;
al?: string;
@ -525,7 +526,7 @@ export class HiFiClient {
},
signal?: AbortSignal
) {
const { s, a, al, v, p, i, offset = 0, limit = 25 } = options;
const { q, s, a, al, v, p, i, offset = 0, limit = 25 } = options;
if (i) {
// try filtered track search first
@ -559,6 +560,11 @@ export class HiFiClient {
}
const mapping: Array<[string | undefined, string, Params]> = [
[
q,
'https://api.tidal.com/v1/search',
{ query: q, limit, offset, types: 'ARTISTS,ALBUMS,TRACKS,VIDEOS,PLAYLISTS', countryCode: this.countryCode },
],
[s, 'https://api.tidal.com/v1/search/tracks', { query: s, limit, offset, countryCode: this.countryCode }],
[
a,
@ -754,6 +760,7 @@ export class HiFiClient {
return await this.getCover(qp.id ? Number(qp.id) : undefined, qp.q ?? undefined, signal);
case '/search':
return await this.search({
q: qp.q,
s: qp.s,
a: qp.a,
al: qp.al,

175
js/api.js
View file

@ -22,6 +22,7 @@ import { DownloadProgress } from './progressEvents.js';
import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
import { readableStreamIterator } from './readableStreamIterator.js';
import { HiFiClient, TidalResponse } from './HiFi.ts';
import { isIos, isSafari } from './platform-detection.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
export { resolveDownloadTotalBytes };
@ -421,6 +422,69 @@ export class LosslessAPI {
return Array.from(unique.values());
}
async search(query, options = {}) {
const cached = await this.cache.get('search_all', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?q=${encodeURIComponent(query)}`, options);
const data = await response.json();
// Check if backend returned an error or if this looks like individual fallback
if (data.error || (!data.tracks && !data.artists && !data.albums && (!data.data || !data.data.tracks))) {
throw new Error('Fallback to individual searches');
}
const extractSection = (key) => this.normalizeSearchResponse(data, key);
const tracksData = extractSection('tracks');
const artistsData = extractSection('artists');
const albumsData = extractSection('albums');
const playlistsData = extractSection('playlists');
const videosData = extractSection('videos');
const results = {
tracks: {
...tracksData,
items: tracksData.items.map(t => this.prepareTrack(t))
},
artists: {
...artistsData,
items: artistsData.items.map(a => this.prepareArtist(a))
},
albums: {
...albumsData,
items: albumsData.items.map(a => this.prepareAlbum(a))
},
playlists: playlistsData ? {
...playlistsData,
items: playlistsData.items.map(p => this.preparePlaylist(p))
} : { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 },
videos: {
...videosData,
items: videosData.items.map(v => this.prepareTrack(v))
}
};
await this.cache.set('search_all', query, results);
return results;
} catch (error) {
// Fallback to individual searches if the backend proxy doesn't support ?q= or throws
const [tracks, videos, artists, albums, playlists] = await Promise.all([
this.searchTracks(query, options).catch(() => ({ items: [] })),
this.searchVideos(query, options).catch(() => ({ items: [] })),
this.searchArtists(query, options).catch(() => ({ items: [] })),
this.searchAlbums(query, options).catch(() => ({ items: [] })),
this.searchPlaylists(query, options).catch(() => ({ items: [] }))
]);
return {
tracks, videos, artists, albums, playlists
};
}
}
async searchTracks(query, options = {}) {
const cached = await this.cache.get('search_tracks', query);
if (cached) return cached;
@ -1309,9 +1373,7 @@ export class LosslessAPI {
if (found) {
track = this.prepareTrack(found.item || found);
if (!(response instanceof TidalResponse)) {
await this.cache.set('track', cacheKey, track);
}
await this.cache.set('track', cacheKey, track);
return track;
}
@ -1359,28 +1421,109 @@ export class LosslessAPI {
}
async getStreamUrl(id, quality = 'HI_RES_LOSSLESS') {
const cacheKey = `stream_${id}_${quality}`;
const cacheKey = `stream_info_${id}_${quality}`;
if (this.streamCache.has(cacheKey)) {
return this.streamCache.get(cacheKey);
}
const lookup = await this.getTrack(id, quality);
let streamUrl;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
let manifestRgInfo = null;
let isUsingManifestEndpoint = false;
try {
const manifestType = (isIos || isSafari) ? 'HLS' : 'MPEG_DASH';
let canPlayAtmos = false;
try {
if (window.MediaSource && typeof window.MediaSource.isTypeSupported === 'function') {
canPlayAtmos = MediaSource.isTypeSupported('audio/mp4; codecs="ec-3"') || MediaSource.isTypeSupported('audio/mp4; codecs="eac3"');
}
if (!canPlayAtmos && typeof document !== 'undefined') {
const a = document.createElement('audio');
canPlayAtmos = !!(a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"'));
}
} catch (e) {}
const paramsArray = [];
if (quality === 'LOW') {
paramsArray.push(['formats', 'HEAACV1']);
} else if (quality === 'HIGH') {
paramsArray.push(['formats', 'AACLC']);
} else if (quality === 'LOSSLESS') {
// For Safari to not auto-downgrade to AAC, we only request FLAC
paramsArray.push(['formats', 'FLAC']);
} else if (quality === 'HI_RES_LOSSLESS') {
// FLAC_HIRES might fallback to FLAC by Tidal if unavailable, but we specify only HIRES and standard FLAC to avoid AAC downgrading
paramsArray.push(['formats', 'FLAC_HIRES']);
paramsArray.push(['formats', 'FLAC']);
} else if (quality === 'DOLBY_ATMOS' && canPlayAtmos) {
paramsArray.push(['formats', 'EAC3_JOC']);
} else {
// Default fallback or "auto" behavior
paramsArray.push(['formats', 'HEAACV1']);
paramsArray.push(['formats', 'AACLC']);
paramsArray.push(['formats', 'FLAC']);
paramsArray.push(['formats', 'FLAC_HIRES']);
if (canPlayAtmos) {
paramsArray.push(['formats', 'EAC3_JOC']);
}
}
paramsArray.push(
['adaptive', 'true'],
['manifestType', manifestType],
['uriScheme', 'HTTPS'],
['usage', 'PLAYBACK']
);
const params = new URLSearchParams(paramsArray);
const response = await this.fetchWithRetry(`/trackManifests/?id=${id}&${params.toString()}`, { type: 'streaming', minVersion: '2.7' });
const jsonResponse = await response.json();
const url = jsonResponse?.data?.data?.attributes?.uri;
if (url) {
streamUrl = url;
manifestRgInfo = {
trackReplayGain: jsonResponse?.data?.data?.attributes?.trackAudioNormalizationData?.replayGain,
trackPeakAmplitude: jsonResponse?.data?.data?.attributes?.trackAudioNormalizationData?.peakAmplitude,
albumReplayGain: jsonResponse?.data?.data?.attributes?.albumAudioNormalizationData?.replayGain,
albumPeakAmplitude: jsonResponse?.data?.data?.attributes?.albumAudioNormalizationData?.peakAmplitude
};
isUsingManifestEndpoint = true;
} else {
throw new Error('No URI in trackManifests response');
}
} catch (err) {
// Fallback to /track endpoint
}
if (!isUsingManifestEndpoint) {
const lookup = await this.getTrack(id, quality);
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
}
}
if (lookup.info) {
manifestRgInfo = {
trackReplayGain: lookup.info.trackReplayGain || lookup.info.replayGain,
trackPeakAmplitude: lookup.info.trackPeakAmplitude || lookup.info.peakAmplitude,
albumReplayGain: lookup.info.albumReplayGain,
albumPeakAmplitude: lookup.info.albumPeakAmplitude
};
}
}
if (!(lookup instanceof TidalResponse)) {
this.streamCache.set(cacheKey, streamUrl);
}
return streamUrl;
const result = { url: streamUrl, rgInfo: manifestRgInfo };
this.streamCache.set(cacheKey, result);
return result;
}
async getVideoStreamUrl(id) {

View file

@ -413,6 +413,7 @@ document.addEventListener('DOMContentLoaded', async () => {
};
removeHiRes(qualitySelect);
removeHiRes(downloadQualitySelect);
if (isIos) {
document.querySelector('#hi-res-download-warning').style.display = '';

View file

@ -1,4 +1,5 @@
import { triggerDownload } from './download-utils';
import { readableStreamIterator } from './readableStreamIterator';
/**
* A single entry to be included in a ZIP archive or written directly to a folder.

View file

@ -530,6 +530,14 @@ class CommandPalette {
},
},
{
id: 'quality-auto',
group: 'Audio',
icon: 'sliders',
label: 'Quality: Auto (Adaptive)',
keywords: ['quality', 'auto', 'adaptive', 'streaming', 'bitrate'],
action: () => this.setQuality('auto'),
},
{
id: 'quality-low',
group: 'Audio',
@ -839,11 +847,10 @@ class CommandPalette {
this.showMusicLoading();
try {
const [tracks, albums, artists] = await Promise.all([
api.searchTracks(query, { limit: 4 }),
api.searchAlbums(query, { limit: 3 }),
api.searchArtists(query, { limit: 3 }),
]);
const results = await api.search(query, { limit: 4 });
const tracks = results.tracks || { items: [] };
const albums = results.albums || { items: [] };
const artists = results.artists || { items: [] };
if (controller.signal.aborted || !this.isOpen) return;
@ -1189,19 +1196,28 @@ class CommandPalette {
}
async setQuality(quality) {
const qualityNames = { LOW: 'Low', HIGH: 'High', LOSSLESS: 'Lossless', HI_RES_LOSSLESS: 'Hi-Res' };
const qualityNames = { auto: 'Auto', LOW: 'Low', HIGH: 'High', LOSSLESS: 'Lossless', HI_RES_LOSSLESS: 'Hi-Res' };
if (Player.instance) {
Player.instance.setQuality(quality);
localStorage.setItem('playback-quality', quality);
// Set fallback API quality (Auto maps back to Hi-Res)
const apiQuality = quality === 'auto' ? 'HI_RES_LOSSLESS' : quality;
Player.instance.setQuality(apiQuality);
localStorage.setItem('playback-quality', apiQuality);
// Set adaptive streaming quality
localStorage.setItem('adaptive-playback-quality', quality);
if (Player.instance.forceQuality) Player.instance.forceQuality(quality);
const streamingSelect = document.getElementById('streaming-quality-setting');
if (streamingSelect) streamingSelect.value = quality;
}
const { downloadQualitySettings } = await import('./storage.js');
downloadQualitySettings.setQuality(quality);
// Do not pass auto to download quality, resolve it to original fallback
const dlQuality = quality === 'auto' ? 'HI_RES_LOSSLESS' : quality;
downloadQualitySettings.setQuality(dlQuality);
const downloadSelect = document.getElementById('download-quality-setting');
if (downloadSelect) downloadSelect.value = quality;
if (downloadSelect) downloadSelect.value = dlQuality;
this.notify(`Quality set to ${qualityNames[quality] || quality}`);
}

View file

@ -1 +0,0 @@
export { MediaPlayer } from 'dashjs';

View file

@ -1,6 +1,7 @@
export { default as SVG_ALIGN_LEFT } from '!lucide/align-left.svg?svg&icon';
export { default as SVG_ANIMATE_SPIN } from '../images/animate-spin.svg?svg&icon';
export { default as SVG_APPLE } from '../images/apple.svg?svg&icon';
export { default as SVG_ATMOS } from '../images/atmos.svg?svg&icon';
export { default as SVG_BIN } from '!lucide/trash-2.svg?svg&icon';
export { default as SVG_CALENDAR } from '!lucide/calendar.svg?svg&icon';
export { default as SVG_CHECK } from '!lucide/check.svg?svg&icon';

View file

@ -44,6 +44,31 @@ export class MusicAPI {
}
// Search methods
async search(query, options = {}) {
const provider = options.provider || this.getCurrentProvider();
const api = this.getAPI(provider);
if (typeof api.search === 'function') {
return api.search(query, options);
}
// Fallback for providers that don't implement unified search
const [tracksResult, videosResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
api.searchTracks(query, options),
api.searchVideos ? api.searchVideos(query, options) : Promise.resolve({ items: [] }),
api.searchArtists(query, options),
api.searchAlbums(query, options),
api.searchPlaylists ? api.searchPlaylists(query, options) : Promise.resolve({ items: [] })
]);
return {
tracks: tracksResult,
videos: videosResult,
artists: artistsResult,
albums: albumsResult,
playlists: playlistsResult
};
}
async searchTracks(query, options = {}) {
const provider = options.provider || this.getCurrentProvider();
return this.getAPI(provider).searchTracks(query, options);

View file

@ -1,4 +1,3 @@
//js/player.js
import {
REPEAT_MODE,
formatTime,
@ -8,6 +7,7 @@ import {
getTrackYearDisplay,
createQualityBadgeHTML,
escapeHtml,
deriveTrackQuality,
} from './utils.js';
import {
queueManager,
@ -18,11 +18,10 @@ import {
radioSettings,
} from './storage.js';
import { audioContextManager } from './audio-context.js';
import { isIos } from './platform-detection.js';
import { isIos, isSafari } from './platform-detection.js';
import { db } from './db.js';
import('./dash-media-player.js');
import { SVG_CLOCK } from './icons.js';
import { SVG_CLOCK, SVG_ATMOS } from './icons.js';
import { UIRenderer } from './ui.js';
export class Player {
@ -97,17 +96,41 @@ export class Player {
});
}
// Initialize dash.js player
const { MediaPlayer } = await import('./dash-media-player.js');
this.dashPlayer = MediaPlayer().create();
this.dashPlayer.updateSettings({
streaming: {
buffer: {
fastSwitchEnabled: true,
// Initialize Shaka player
const shaka = await import('shaka-player');
shaka.polyfill.installAll();
if (shaka.Player.isBrowserSupported()) {
this.shakaPlayer = new shaka.Player();
this.shakaPlayer.configure({
streaming: {
bufferingGoal: 30,
rebufferingGoal: 2,
bufferBehind: 30,
jumpLargeGaps: true
},
},
});
this.dashInitialized = false;
abr: {
enabled: true,
// Start with a low bandwidth estimate (200kbps) so it plays instantly
// on slow connections and smoothly scales UP to Hi-Fi if the connection allows.
defaultBandwidthEstimate: 100000,
switchInterval: 1, // Check more frequently
bandwidthDowngradeTarget: 0.8, // Downgrade more aggressively if bandwidth drops
restrictToElementSize: false
},
mediaSource: {
codecSwitchingStrategy: 'smooth'
}
});
this.shakaPlayer.addEventListener('adaptation', this.updateAdaptiveQualityBadge.bind(this));
this.shakaPlayer.addEventListener('variantchanged', this.updateAdaptiveQualityBadge.bind(this));
this.shakaInitialized = false;
// Monitor and bridge different codec groups (e.g. AAC to FLAC) since native ABR isolates them
setInterval(this.evaluateCrossCodecAbr.bind(this), 3000);
} else {
console.error('Browser not supported for Shaka Player');
}
this.loadQueueState();
this.setupMediaSession();
@ -207,12 +230,18 @@ export class Player {
const el = this.activeElement;
// Apply to audio element and/or Web Audio graph
if (audioContextManager.isReady()) {
const isApple = isIos || isSafari;
if (audioContextManager.isReady() && !isApple) {
// If Web Audio is active, we apply volume there for better compatibility
// Especially on Linux where audio.volume might not affect the Web Audio graph
el.volume = 1.0;
audioContextManager.setVolume(effectiveVolume);
} else {
// Safari bypasses WebAudio for HLS, so we MUST set el.volume directly to reflect ReplayGain
if (audioContextManager.isReady()) {
audioContextManager.setVolume(1.0); // Reset graph gain if it somehow routes
}
el.volume = Math.max(0, Math.min(1, effectiveVolume));
}
}
@ -221,14 +250,8 @@ export class Player {
const speed = audioEffectsSettings.getSpeed();
const el = this.activeElement;
if (this.dashInitialized && this.dashPlayer) {
if (this.dashPlayer.getPlaybackRate() !== speed) {
this.dashPlayer.setPlaybackRate(speed);
}
} else {
if (el.playbackRate !== speed) {
el.playbackRate = speed;
}
if (el.playbackRate !== speed) {
el.playbackRate = speed;
}
const preservePitch = audioEffectsSettings.isPreservePitchEnabled();
@ -470,15 +493,15 @@ export class Player {
const isPodcast = track.isPodcast || (track.id && String(track.id).startsWith('podcast_'));
if (track.isLocal || isTracker || isPodcast || (track.audioUrl && !track.isLocal)) continue;
try {
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
const streamInfo = await this.api.getStreamUrl(track.id, this.quality);
if (this.preloadAbortController.signal.aborted) break;
this.preloadCache.set(track.id, streamUrl);
this.preloadCache.set(track.id, streamInfo);
// Warm connection/cache
// For Blob URLs (DASH), this head request is not needed and can cause errors.
if (!streamUrl.startsWith('blob:')) {
fetch(streamUrl, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch(() => {});
if (!streamInfo.url.startsWith('blob:')) {
fetch(streamInfo.url, { method: 'HEAD', signal: this.preloadAbortController.signal }).catch(() => {});
}
} catch (error) {
if (error.name !== 'AbortError') {
@ -690,9 +713,9 @@ export class Player {
this.hls.destroy();
this.hls = null;
}
if (this.dashInitialized) {
this.dashPlayer.reset();
this.dashInitialized = false;
if (this.shakaInitialized && this.shakaPlayer) {
this.shakaPlayer.unload();
this.shakaInitialized = false;
}
if (inactiveElement) {
@ -895,8 +918,14 @@ export class Player {
if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
await this.setupHlsVideo(activeElement, streamUrl, null);
} else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) {
this.dashPlayer.initialize(activeElement, streamUrl, false);
this.dashInitialized = true;
await this.shakaPlayer.attach(activeElement);
await this.shakaPlayer.load(streamUrl);
this.shakaInitialized = true;
const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto';
this.forceQuality(savedAdaptiveQuality);
this.updateAdaptiveQualityBadge();
} else {
activeElement.src = streamUrl;
}
@ -920,50 +949,63 @@ export class Player {
this.applyReplayGain();
if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id);
streamUrl = this.preloadCache.get(track.id).url;
} else {
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
const streamInfo = await this.api.getStreamUrl(track.id, this.quality);
streamUrl = streamInfo.url;
}
} else {
// Tidal: Get track data for ReplayGain (should be cached by API)
const trackData = await this.api.getTrack(track.id, this.quality);
// Tidal: Try to get ReplayGain from manifest first, supplement with track info if needed
const streamInfoPromise = this.preloadCache.has(track.id)
? Promise.resolve(this.preloadCache.get(track.id))
: this.api.getStreamUrl(track.id, this.quality);
// We only need the legacy track info if we missed getting ReplayGain from the manifest endpoint
const resolvedStreamInfo = await streamInfoPromise;
if (this.playbackSequence !== currentSequence) return;
streamUrl = resolvedStreamInfo.url;
if (trackData && trackData.info) {
this.currentRgValues = {
trackReplayGain: trackData.info.trackReplayGain,
trackPeakAmplitude: trackData.info.trackPeakAmplitude,
albumReplayGain: trackData.info.albumReplayGain,
albumPeakAmplitude: trackData.info.albumPeakAmplitude,
};
if (resolvedStreamInfo.rgInfo) {
this.currentRgValues = resolvedStreamInfo.rgInfo;
this.applyReplayGain();
} else {
this.currentRgValues = null;
}
this.applyReplayGain();
// Fallback to legacy metadata if manifest lacked normalization data
const trackData = await this.api.getTrack(track.id, this.quality).catch(() => null);
if (this.playbackSequence !== currentSequence) return;
if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id);
} else if (trackData.originalTrackUrl) {
streamUrl = trackData.originalTrackUrl;
} else if (trackData.info?.manifest) {
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
} else {
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
if (trackData && trackData.info) {
this.currentRgValues = {
trackReplayGain: trackData.info.trackReplayGain,
trackPeakAmplitude: trackData.info.trackPeakAmplitude,
albumReplayGain: trackData.info.albumReplayGain,
albumPeakAmplitude: trackData.info.albumPeakAmplitude,
};
} else {
this.currentRgValues = null;
}
this.applyReplayGain();
}
}
if (this.playbackSequence !== currentSequence) return;
// Handle playback
if (streamUrl && streamUrl.startsWith('blob:') && !track.isLocal) {
// It's likely a DASH manifest blob URL
this.dashPlayer.initialize(activeElement, streamUrl, false);
this.dashInitialized = true;
this.applyAudioEffects();
if (streamUrl && (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) && !track.isLocal) {
// It's likely a DASH manifest URL
await this.shakaPlayer.attach(activeElement);
if (startTime > 0) {
this.dashPlayer.seek(startTime);
await this.shakaPlayer.load(streamUrl, startTime);
} else {
await this.shakaPlayer.load(streamUrl);
}
this.shakaInitialized = true;
this.applyAudioEffects();
const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto';
this.forceQuality(savedAdaptiveQuality);
this.updateAdaptiveQualityBadge();
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
if (!canPlay || this.playbackSequence !== currentSequence) return;
@ -971,6 +1013,7 @@ export class Player {
} else {
activeElement.src = streamUrl;
this.applyAudioEffects();
this.updateAdaptiveQualityBadge();
// Wait for audio to be ready before playing
const canPlay = await this.waitForCanPlayOrTimeout(activeElement);
@ -1286,7 +1329,9 @@ export class Player {
handlePlayPause() {
const el = this.activeElement;
if (!el.src || el.error) {
const hasSource = el.src || el.currentSrc || el.srcObject || this.shakaInitialized;
if (!hasSource || el.error) {
if (this.currentTrack) {
this.playTrackFromQueue(0, 0);
}
@ -1606,6 +1651,238 @@ export class Player {
});
}
updateAdaptiveQualityBadge() {
if (!this.currentTrack) return;
try {
const titleEl = document.querySelector('.now-playing-bar .title');
if (!titleEl) return;
let badgeEl = titleEl.querySelector('.shaka-quality-badge');
// Determine if the track is inherently an Atmos track based on metadata
const trackBaseQuality = deriveTrackQuality(this.currentTrack);
const isTrackAtmos = trackBaseQuality === 'DOLBY_ATMOS' || this.currentTrack?.audioQuality === 'DOLBY_ATMOS';
if (this.shakaInitialized) {
const variants = this.shakaPlayer.getVariantTracks();
const activeVariant = variants.find(t => t.active);
if (activeVariant) {
if (!badgeEl) {
badgeEl = document.createElement('span');
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
badgeEl.title = 'Adaptive Stream Quality';
titleEl.appendChild(badgeEl);
const staticBadge = titleEl.querySelector('.quality-badge:not(.shaka-quality-badge)');
if (staticBadge) staticBadge.style.display = 'none';
}
let text = '';
let isAtmosPlaying = false;
if (activeVariant.videoBandwidth && activeVariant.height) {
text = `${activeVariant.height}p`;
} else if (activeVariant.audioCodec) {
const codec = activeVariant.audioCodec.toLowerCase();
if (codec.includes('flac')) {
const sampleRate = activeVariant.audioSamplingRate ? activeVariant.audioSamplingRate / 1000 : 44.1;
if (sampleRate > 48 || activeVariant.audioBandwidth > 1200000) {
text = `HD 24/${sampleRate}`;
} else {
text = 'FLAC';
}
} else if (codec.includes('mp4a')) {
text = 'AAC';
} else if (codec.includes('ec-3') || codec.includes('ac-3')) {
if (codec.includes('joc') || codec === 'ec-3') {
isAtmosPlaying = true;
} else {
text = 'Dolby';
}
} else {
text = activeVariant.audioCodec;
}
if (activeVariant.audioBandwidth && !text.includes('FLAC') && !text.includes('HD') && !isAtmosPlaying) {
text += ` ${Math.round(activeVariant.audioBandwidth / 1000)}k`;
}
} else {
text = 'Auto';
}
if (isAtmosPlaying) {
badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge';
badgeEl.innerHTML = SVG_ATMOS(20);
} else {
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
badgeEl.textContent = text;
}
badgeEl.style.display = (text || isAtmosPlaying) ? 'inline-flex' : 'none';
}
} else if ((isIos || isSafari) && this.activeElement && this.activeElement.src && (this.activeElement.src.includes('.m3u8') || this.currentTrack)) {
if (!badgeEl) {
badgeEl = document.createElement('span');
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
badgeEl.title = 'HLS Stream Quality';
titleEl.appendChild(badgeEl);
const staticBadge = titleEl.querySelector('.quality-badge:not(.shaka-quality-badge)');
if (staticBadge) staticBadge.style.display = 'none';
}
let text = '';
// Ensure device can actually decode Atmos before rendering logo for HLS
let deviceSupportsAtmos = false;
try {
if (window.MediaSource && typeof window.MediaSource.isTypeSupported === 'function') {
deviceSupportsAtmos = MediaSource.isTypeSupported('audio/mp4; codecs="ec-3"') || MediaSource.isTypeSupported('audio/mp4; codecs="eac3"');
}
if (!deviceSupportsAtmos && typeof document !== 'undefined') {
const a = document.createElement('audio');
deviceSupportsAtmos = !!(a.canPlayType('audio/mp4; codecs="ec-3"') || a.canPlayType('audio/mp4; codecs="eac3"'));
}
} catch (e) {}
let isAtmosPlaying = isTrackAtmos && deviceSupportsAtmos;
const q = this.quality || localStorage.getItem('adaptive-playback-quality') || 'auto';
if (!isAtmosPlaying) {
if (q === 'HI_RES_LOSSLESS') text = 'HD FLAC';
else if (q === 'LOSSLESS') text = 'FLAC';
else if (q === 'HIGH') text = 'AAC';
else if (q === 'LOW') text = 'AAC Low';
else if (q === 'auto') text = 'HLS Auto';
else text = 'HLS';
}
if (isAtmosPlaying) {
badgeEl.innerHTML = SVG_ATMOS(20);
badgeEl.className = 'quality-badge quality-atmos shaka-quality-badge';
} else {
badgeEl.textContent = text;
badgeEl.className = 'quality-badge quality-hires shaka-quality-badge';
}
badgeEl.style.display = 'inline-flex';
} else {
if (badgeEl) badgeEl.style.display = 'none';
}
} catch (e) {
console.error('Failed to update adaptive quality badge', e);
}
}
evaluateCrossCodecAbr() {
if (!this.shakaInitialized || !this.shakaPlayer || this.shakaPlayer.isBuffering() || this.activeElement.paused) return;
try {
const stats = this.shakaPlayer.getStats();
const estimatedBandwidth = stats.estimatedBandwidth;
if (!estimatedBandwidth) return;
const variants = this.shakaPlayer.getVariantTracks();
if (variants.length < 2) return;
const activeVariant = variants.find(v => v.active);
if (!activeVariant) return;
// Sort variants by bandwidth descending
const sortedVariants = [...variants].sort((a, b) => b.bandwidth - a.bandwidth);
const safeUpBandwidth = estimatedBandwidth * 0.85;
let bestVariant = sortedVariants[0];
for (const variant of sortedVariants) {
if (variant.bandwidth <= safeUpBandwidth) {
bestVariant = variant;
break;
}
}
if (sortedVariants[sortedVariants.length - 1].bandwidth > safeUpBandwidth) {
bestVariant = sortedVariants[sortedVariants.length - 1];
}
if (bestVariant.audioCodec !== activeVariant.audioCodec && bestVariant.id !== activeVariant.id) {
// To safely cross AdaptationSet boundaries in Shaka, explicitly select the track
this.shakaPlayer.configure({ preferredAudioCodecs: [bestVariant.audioCodec] });
this.shakaPlayer.selectVariantTrack(bestVariant, false, 0); // false = don't clear buffer, smooth transition
// Re-enable ABR so it can dynamically downgrade within that new codec family if needed
this.shakaPlayer.configure({ abr: { enabled: true } });
}
} catch (e) {
// fail silently on abr checks
}
}
forceQuality(quality) {
if (!this.shakaInitialized || !this.shakaPlayer) return;
try {
if (quality === 'auto') {
this.shakaPlayer.configure({
abr: { enabled: true },
preferredAudioCodecs: []
});
return;
}
const variants = this.shakaPlayer.getVariantTracks();
if (variants.length === 0) return;
let bestVariant = variants[0];
if (quality === 'LOW' || quality === 'HIGH') {
const targetBandwidth = quality === 'LOW' ? 96000 : 320000;
const aacVariants = variants.filter(v => v.audioCodec && v.audioCodec.toLowerCase().includes('mp4a'));
const searchVariants = aacVariants.length > 0 ? aacVariants : variants;
let minDiff = Infinity;
for (const variant of searchVariants) {
const bw = variant.audioBandwidth || variant.bandwidth;
const diff = Math.abs(bw - targetBandwidth);
if (diff < minDiff) {
minDiff = diff;
bestVariant = variant;
}
}
} else if (quality === 'LOSSLESS' || quality === 'HI_RES_LOSSLESS') {
const flacVariants = variants.filter(v => v.audioCodec && v.audioCodec.toLowerCase().includes('flac'));
if (flacVariants.length > 0) {
if (quality === 'HI_RES_LOSSLESS') {
// Find highest quality FLAC
bestVariant = flacVariants.reduce((prev, current) => {
const prevBw = prev.audioBandwidth || prev.bandwidth || 0;
const currBw = current.audioBandwidth || current.bandwidth || 0;
return (currBw > prevBw) ? current : prev;
}, flacVariants[0]);
} else {
// Find standard lossless (lowest bandwidth FLAC, usually 16-bit 44.1kHz)
bestVariant = flacVariants.reduce((prev, current) => {
const prevBw = prev.audioBandwidth || prev.bandwidth || 0;
const currBw = current.audioBandwidth || current.bandwidth || 0;
return (currBw < prevBw) ? current : prev;
}, flacVariants[0]);
}
} else {
// Fallback to highest overall
bestVariant = variants.reduce((prev, current) => {
const prevBw = prev.audioBandwidth || prev.bandwidth || 0;
const currBw = current.audioBandwidth || current.bandwidth || 0;
return (currBw > prevBw) ? current : prev;
}, variants[0]);
}
}
this.shakaPlayer.configure({ abr: { enabled: false } });
if (bestVariant.audioCodec) {
this.shakaPlayer.configure({ preferredAudioCodecs: [bestVariant.audioCodec] });
}
this.shakaPlayer.selectVariantTrack(bestVariant, false, 0); // false = don't clear buffer, smooth transition
} catch (e) {
console.error('Failed to force quality', e);
}
}
updateMediaSession(track) {
if (!('mediaSession' in navigator)) return;

View file

@ -800,14 +800,28 @@ export async function initializeSettings(scrobbler, player, api, ui) {
// Streaming Quality setting
const streamingQualitySetting = document.getElementById('streaming-quality-setting');
if (streamingQualitySetting) {
const savedQuality = localStorage.getItem('playback-quality') || 'HI_RES_LOSSLESS';
streamingQualitySetting.value = savedQuality;
player.setQuality(savedQuality);
const savedAdaptiveQuality = localStorage.getItem('adaptive-playback-quality') || 'auto';
// Map the stored auto state to the dropdown, or if it doesn't match an option, use the playback-quality value
const optionExists = Array.from(streamingQualitySetting.options).some(opt => opt.value === savedAdaptiveQuality);
streamingQualitySetting.value = optionExists ? savedAdaptiveQuality : (localStorage.getItem('playback-quality') || 'auto');
// Apply initially
if (player.forceQuality) player.forceQuality(streamingQualitySetting.value);
const apiQuality = streamingQualitySetting.value === 'auto' ? 'HI_RES_LOSSLESS' : streamingQualitySetting.value;
player.setQuality(localStorage.getItem('playback-quality') || apiQuality);
streamingQualitySetting.addEventListener('change', (e) => {
const newQuality = e.target.value;
player.setQuality(newQuality);
localStorage.setItem('playback-quality', newQuality);
const val = e.target.value;
// Set adaptive DASH quality
localStorage.setItem('adaptive-playback-quality', val);
if (player.forceQuality) player.forceQuality(val);
// Set fallback API quality
const newApiQuality = val === 'auto' ? 'HI_RES_LOSSLESS' : val;
player.setQuality(newApiQuality);
localStorage.setItem('playback-quality', newApiQuality);
});
}
@ -816,6 +830,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
if (downloadQualitySetting) {
// Assign categories to the static (native) options already in the HTML
const staticCategories = {
DOLBY_ATMOS: 'Spatial',
HI_RES_LOSSLESS: 'Lossless',
LOSSLESS: 'Lossless',
HIGH: 'AAC',
@ -842,7 +857,7 @@ export async function initializeSettings(scrobbler, player, api, ui) {
const m = text.match(/(\d+)\s*kbps/i);
return m ? parseInt(m[1], 10) : Infinity;
};
const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
const categoryOrder = ['Spatial', 'Lossless', 'AAC', 'MP3', 'OGG'];
allOptions.sort((a, b) => {
if (a.category == b.category && a.category === 'Lossless') return 0; // Preserve original order for lossless options
const ai = categoryOrder.indexOf(a.category);

View file

@ -2814,19 +2814,13 @@ export class UIRenderer {
try {
const provider = this.api.getCurrentProvider();
const [tracksResult, videosResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
this.api.searchTracks(query, { signal, provider }),
this.api.searchVideos(query, { signal, provider }),
this.api.searchArtists(query, { signal, provider }),
this.api.searchAlbums(query, { signal, provider }),
this.api.searchPlaylists(query, { signal, provider }),
]);
const results = await this.api.search(query, { signal, provider });
let finalTracks = tracksResult.items;
let finalVideos = videosResult.items || [];
let finalArtists = artistsResult.items;
let finalAlbums = albumsResult.items;
let finalPlaylists = playlistsResult.items;
let finalTracks = (results.tracks && results.tracks.items) || [];
let finalVideos = (results.videos && results.videos.items) || [];
let finalArtists = (results.artists && results.artists.items) || [];
let finalAlbums = (results.albums && results.albums.items) || [];
let finalPlaylists = (results.playlists && results.playlists.items) || [];
if (finalArtists.length === 0 && finalTracks.length > 0) {
const artistMap = new Map();
@ -5104,12 +5098,6 @@ export class UIRenderer {
</button>`
: ''
}
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
${SVG_MOVE_UP(16)}
</button>
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
${SVG_MOVE_DOWN(16)}
</button>
</div>
</li>
`;

View file

@ -1,5 +1,6 @@
//js/utils.js
import { modernSettings } from './ModernSettings.js';
import { SVG_ATMOS } from './icons.js';
import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js';
export const QUALITY = 'HI_RES_LOSSLESS';
@ -11,15 +12,17 @@ export const REPEAT_MODE = {
};
export const AUDIO_QUALITIES = {
DOLBY_ATMOS: 'DOLBY_ATMOS',
HI_RES_LOSSLESS: 'HI_RES_LOSSLESS',
LOSSLESS: 'LOSSLESS',
HIGH: 'HIGH',
LOW: 'LOW',
};
export const QUALITY_PRIORITY = ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'];
export const QUALITY_PRIORITY = ['DOLBY_ATMOS', 'HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'];
export const QUALITY_TOKENS = {
DOLBY_ATMOS: ['DOLBY_ATMOS', 'ATMOS'],
HI_RES_LOSSLESS: [
'HI_RES_LOSSLESS',
'HIRES_LOSSLESS',
@ -240,6 +243,7 @@ export const getExtensionForQuality = (quality) => {
switch (quality) {
case 'LOW':
case 'HIGH':
case 'DOLBY_ATMOS':
return 'm4a';
default:
return 'flac';
@ -288,7 +292,9 @@ export const createQualityBadgeHTML = (track) => {
if (!qualityBadgeSettings.isEnabled()) return '';
const quality = deriveTrackQuality(track);
if (quality === 'HI_RES_LOSSLESS') {
if (quality === 'DOLBY_ATMOS') {
return `<span class="quality-badge quality-atmos" title="Dolby Atmos">${SVG_ATMOS(20)}</span>`;
} else if (quality === 'HI_RES_LOSSLESS') {
return '<span class="quality-badge quality-hires" title="Hi-Res Lossless">HD</span>';
}
return '';

View file

@ -64,7 +64,6 @@
"butterchurn-presets": "^2.4.7",
"client-zip": "^2.5.0",
"cookie-session": "^2.1.1",
"dashjs": "https://github.com/Dash-Industry-Forum/dash.js/archive/refs/tags/v5.1.1.tar.gz",
"eventemitter3": "^5.0.4",
"fuse.js": "^7.1.0",
"hls.js": "^1.6.15",
@ -73,6 +72,7 @@
"mime": "^4.1.0",
"npm": "^11.11.1",
"pocketbase": "^0.26.8",
"shaka-player": "^5.0.7",
"simple-icons": "^16.12.0",
"svgo": "^4.0.1",
"url-toolkit": "^2.2.5",

View file

@ -1982,6 +1982,24 @@ input[type='search']::-webkit-search-cancel-button {
line-height: 1;
}
.quality-atmos {
border: 1px solid var(--secondary);
color: var(--muted-foreground);
padding: 0.15rem 0.3rem;
border-radius: var(--radius-xs);
margin-left: 0.5rem;
vertical-align: middle;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.quality-atmos svg {
height: 0.6rem;
width: auto;
}
.track-list {
display: flex;
flex-direction: column;

8
test-search.js Normal file
View file

@ -0,0 +1,8 @@
import { HiFiClient } from './js/HiFi.ts';
async function test() {
const client = new HiFiClient();
const res = await client.queryResponse('/search/?q=alskdjfalksjdfld&limit=5');
const json = await res.json();
console.log(JSON.stringify(json.data || {}));
}
test();