feat: add Atmos support, use new API endpoint, streamline API caching
This commit is contained in:
parent
152f1ac99b
commit
d783642401
18 changed files with 631 additions and 173 deletions
56
bun.lock
56
bun.lock
|
|
@ -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
1
images/atmos.svg
Normal 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 |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
175
js/api.js
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
};
|
||||
|
||||
removeHiRes(qualitySelect);
|
||||
removeHiRes(downloadQualitySelect);
|
||||
|
||||
if (isIos) {
|
||||
document.querySelector('#hi-res-download-warning').style.display = '';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { MediaPlayer } from 'dashjs';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
401
js/player.js
401
js/player.js
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
24
js/ui.js
24
js/ui.js
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
10
js/utils.js
10
js/utils.js
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
18
styles.css
18
styles.css
|
|
@ -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
8
test-search.js
Normal 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();
|
||||
Loading…
Reference in a new issue