From d783642401dc46936b5858eab97ae9abc27f8ea9 Mon Sep 17 00:00:00 2001 From: binimum Date: Sun, 22 Mar 2026 20:08:30 +0000 Subject: [PATCH] feat: add Atmos support, use new API endpoint, streamline API caching --- bun.lock | 56 +----- images/atmos.svg | 1 + index.html | 6 +- js/HiFi.ts | 9 +- js/api.js | 175 ++++++++++++++-- js/app.js | 1 + js/bulk-download-writer.ts | 1 + js/commandPalette.js | 36 +++- js/dash-media-player.ts | 1 - js/icons.ts | 1 + js/music-api.js | 25 +++ js/player.js | 401 +++++++++++++++++++++++++++++++------ js/settings.js | 29 ++- js/ui.js | 24 +-- js/utils.js | 10 +- package.json | 2 +- styles.css | 18 ++ test-search.js | 8 + 18 files changed, 631 insertions(+), 173 deletions(-) create mode 100644 images/atmos.svg delete mode 100644 js/dash-media-player.ts create mode 100644 test-search.js diff --git a/bun.lock b/bun.lock index a32b58e..6d4a88a 100644 --- a/bun.lock +++ b/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=="], diff --git a/images/atmos.svg b/images/atmos.svg new file mode 100644 index 0000000..b55e3c3 --- /dev/null +++ b/images/atmos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/index.html b/index.html index 710cf45..2d22a0c 100644 --- a/index.html +++ b/index.html @@ -3705,10 +3705,11 @@
Streaming Quality - Quality for streaming playback + Default playback quality for streams
+ diff --git a/js/HiFi.ts b/js/HiFi.ts index 61c45f8..51ae7d0 100644 --- a/js/HiFi.ts +++ b/js/HiFi.ts @@ -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, diff --git a/js/api.js b/js/api.js index d377043..8729058 100644 --- a/js/api.js +++ b/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) { diff --git a/js/app.js b/js/app.js index 1938f69..9896982 100644 --- a/js/app.js +++ b/js/app.js @@ -413,6 +413,7 @@ document.addEventListener('DOMContentLoaded', async () => { }; removeHiRes(qualitySelect); + removeHiRes(downloadQualitySelect); if (isIos) { document.querySelector('#hi-res-download-warning').style.display = ''; diff --git a/js/bulk-download-writer.ts b/js/bulk-download-writer.ts index e5d7551..4290166 100644 --- a/js/bulk-download-writer.ts +++ b/js/bulk-download-writer.ts @@ -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. diff --git a/js/commandPalette.js b/js/commandPalette.js index b040542..5a89706 100644 --- a/js/commandPalette.js +++ b/js/commandPalette.js @@ -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}`); } diff --git a/js/dash-media-player.ts b/js/dash-media-player.ts deleted file mode 100644 index d0e76bb..0000000 --- a/js/dash-media-player.ts +++ /dev/null @@ -1 +0,0 @@ -export { MediaPlayer } from 'dashjs'; diff --git a/js/icons.ts b/js/icons.ts index 29ba9ee..39ebf20 100644 --- a/js/icons.ts +++ b/js/icons.ts @@ -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'; diff --git a/js/music-api.js b/js/music-api.js index 8d527d6..6e5cf27 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -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); diff --git a/js/player.js b/js/player.js index 7cf1ba3..7e08b13 100644 --- a/js/player.js +++ b/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; diff --git a/js/settings.js b/js/settings.js index 98119c3..02fd1af 100644 --- a/js/settings.js +++ b/js/settings.js @@ -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); diff --git a/js/ui.js b/js/ui.js index 071ab17..d0b058c 100644 --- a/js/ui.js +++ b/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 { ` : '' } - -
`; diff --git a/js/utils.js b/js/utils.js index e6d5856..6c78b17 100644 --- a/js/utils.js +++ b/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 `${SVG_ATMOS(20)}`; + } else if (quality === 'HI_RES_LOSSLESS') { return 'HD'; } return ''; diff --git a/package.json b/package.json index 999cf2d..28258a0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/styles.css b/styles.css index 9e96209..f6c5902 100644 --- a/styles.css +++ b/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; diff --git a/test-search.js b/test-search.js new file mode 100644 index 0000000..adf119f --- /dev/null +++ b/test-search.js @@ -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();