Compare commits

..

236 Commits

Author SHA1 Message Date
wukko
6a13ca347d
api/request/local-processing: don't return an empty audio object
& also throw errors whenever a response is invalid
2025-03-19 13:38:55 +06:00
wukko
9eb342e6d2
web/queue: use the updated local processing api response
& finally remove mime from the web build
2025-03-19 12:25:51 +06:00
wukko
e497ea51f1
api/request: reformat the local processing response, add output mimetype 2025-03-19 12:24:26 +06:00
wukko
a8bffc4b27
web/layout: load the plausible script only once
oops
2025-03-17 17:37:00 +06:00
wukko
3295032882
web/layout: don't load the plausible script when analytics are disabled
addresses #1136
2025-03-17 17:19:50 +06:00
wukko
93ff9b62d6
web/DialogContainer: prevent an error after a race condition
an error is no longer thrown if several dialogs were closed while timeout was running

this should really be replaced by proper dialog management system, with each dialog having a unique id and removal happening via that id, not just array.pop()
2025-03-17 16:47:03 +06:00
wukko
5850b1ac87
web/layout: preload meowbalt art after the page was loaded 2025-03-17 15:29:51 +06:00
wukko
97fee5e6d4
merge: updates from main 2025-03-15 21:25:23 +06:00
wukko
903998913f
web/PageNavTab: add a border to inactive tab icon 2025-03-13 18:54:33 +06:00
wukko
2197d9411e
merge: updates from main 2025-03-13 14:56:49 +06:00
wukko
e6e2fea870
web/layout: preload meowbalt assets
no more flickering i hope

is this rational? maybe not so much, but it makes cobalt feel like a native app
2025-03-11 13:26:44 +06:00
wukko
429591c445
web/FilenamePreview: reduce line height 2025-03-10 13:47:53 +06:00
wukko
95a5a8ae9b
web/haptics: fix disableHaptics setting path
bub fix
2025-03-07 21:50:02 +06:00
wukko
a5172b8fb4
web/settings/accessibility: add toggle for disabling auto opening queue 2025-03-07 21:47:02 +06:00
wukko
1b0be14175
web/settings: move accessibility settings to the accessibility page
also rearranged the nav bar a bit to make it look cleaner

... and also accommodated for the new location of accessibility settings (oops)
2025-03-07 21:43:03 +06:00
wukko
4a5f0aa52c
web/queue-visibility: don't auto open the queue if disabled in settings 2025-03-07 21:36:54 +06:00
wukko
1f0abf5169
web/lib/settings: add accessibility section, add dontAutoOpenQueue
moved `reduceMotion`, `reduceTransparency`, and `disableHaptics` to accessibility, migrated first two from old version of settings
2025-03-07 21:35:39 +06:00
wukko
1137ccfd3b
web/ProcessingQueue: open the queue popover when new item is added 2025-03-07 21:03:50 +06:00
wukko
714e71751e
web/PopoverContainer: refactor & simplify code
why the fuck was it that way in the first place
2025-03-07 20:47:43 +06:00
wukko
3935396709
web/i18n/queue: update running remux text 2025-03-07 15:48:14 +06:00
wukko
7dc2683180
web/i18n/queue: update the queue title 2025-03-07 15:20:50 +06:00
wukko
dab88f7ed8
web/ProcessingStatus: update the icon 2025-03-07 15:20:34 +06:00
wukko
187bf9d745
merge: api 10.7.7 from main 2025-03-07 00:07:52 +06:00
wukko
a84b21a501
web/runners/remux: retry to run the worker 10 times awkwardly
this is absolutely foul and really needs fixing but i guess it works for now 😭
2025-03-06 22:50:42 +06:00
wukko
4a1780ab7f
web/ProcessingQueueItem: refactor, retry action, rtl optimization
also:
- added a spinner to "running" state
- moved steps counter to the starting state, aka when the worker is loading in
2025-03-06 18:30:48 +06:00
wukko
6a4de1be28
web/PopoverContainer: flip transform origin in rtl 2025-03-06 17:43:30 +06:00
wukko
d8b274f554
web/layout: global spinner animation 2025-03-06 17:22:08 +06:00
wukko
0bee4b1ade
web/queue/createSavePipeline: store original request & allow to retry 2025-03-06 17:04:47 +06:00
wukko
a3a273a4b1
web/queue: add canRetry and originalRequest to queue items 2025-03-06 17:03:55 +06:00
wukko
158ba6f28f
web/saving-handler: destructure params, reuse request if passed 2025-03-06 17:02:06 +06:00
wukko
d98cb4c2d7
web/util/formatFileSize: don't parseFloat, allow .00 to stick around
prevents rapid jiggle in the queue
2025-03-06 16:57:49 +06:00
wukko
f9c0decd4c
web/api: move api request creation to saving-handler & limit the type
prerequisites for reusing the requests 👀
2025-03-06 15:58:31 +06:00
wukko
9225b31986
web/workers/fetch: retry 5 more times before throwing an error
hopium

should probably add a timeout too
2025-03-06 14:30:52 +06:00
wukko
066a47c82d
web/DownloadButton: fix the button width to prevent moving around 2025-03-06 14:25:31 +06:00
wukko
1f38bf822c
web/app.html: remove error art prefetch cuz it makes no difference 2025-03-06 13:40:32 +06:00
jj
e8967c33d3
web/static: recompress all pngs 2025-03-05 16:53:59 +00:00
wukko
4f92ccf813
web/app.html: preload meowbalt error art
previously it just snapped into the error popup which was very ugly
2025-03-05 22:33:05 +06:00
wukko
7e71701e10
web/SmallDialog: add error haptics to error popups 2025-03-05 22:19:08 +06:00
wukko
a2e08b9ccb
web/DownloadButton: refactor & add haptic feedback 2025-03-05 22:05:11 +06:00
wukko
bf0b9f55e5
web/Omnibox: add haptic feedback to the paste button 2025-03-05 22:04:50 +06:00
wukko
698905db2e
web/settings/appearance: add a toggle for disabling haptics
also updated all descriptions for accessibility toggles
2025-03-05 21:46:27 +06:00
wukko
712318612d
web/haptics: don't use haptics if disabled in settings 2025-03-05 21:40:26 +06:00
wukko
8af4c69be3
web/settings: add disableHaptics 2025-03-05 21:38:47 +06:00
wukko
e61ac61e20
web/settings/local: hide the webcodecs toggle if the feature not enabled 2025-03-05 20:36:09 +06:00
wukko
a3c9ccf5df
web/env: temporary ENABLE_WEBCODECS variable 2025-03-05 20:35:10 +06:00
wukko
6e21fc56eb
web/SettingsDropdown: add haptics 2025-03-05 20:18:52 +06:00
wukko
ef7fc8781b
web/SettingsToggle: remove bg transition
cuz it was making the light/dark theme transition very awkward
2025-03-05 20:09:52 +06:00
wukko
0d3044c5e6
web: add haptics for all copy actions
& prevent spamming the copy action along with haptic feedback :3

should probably unify all of this cuz this is really messy
2025-03-05 18:07:46 +06:00
wukko
fd5f7c36b2
web/Toggle: jiggle physics & don't stretch on long press 2025-03-05 17:30:15 +06:00
wukko
6b09bd4688
web: add haptics to toggles & switchers 2025-03-05 17:21:45 +06:00
wukko
66401c6c5f
web/UpdateNotification: replace animation with a springy transition
so cute :3
2025-02-27 21:05:29 +06:00
wukko
64680e162a
web/Switcher: add box-shadow to active item 2025-02-27 20:41:11 +06:00
wukko
96142a3a0c
web/SettingsToggle: make border shine when pressed 2025-02-27 20:40:13 +06:00
wukko
3651b98b2d
web/DonateShareCard: reduce action button padding
might help with translations
2025-02-27 20:12:40 +06:00
wukko
dc0803d292
web/DonateCardContainer: don't show bg on scroll buttons 2025-02-27 19:17:58 +06:00
wukko
8934b25c47
web/DonateCardContainer: default cursor when a button is selected
also disabled hover & active for a selected button
2025-02-27 18:29:07 +06:00
wukko
238295888c
web/DonateOptionsCard: faster scrolling, hover state for custom value 2025-02-27 18:15:51 +06:00
wukko
f5b9f59e43
web/DonateCardContainer: add an active button state 2025-02-27 18:12:15 +06:00
wukko
0b631b31b3
web/DonateAltItem: add missing button class 2025-02-27 18:05:42 +06:00
wukko
b4dd9efd92
web/PageNavTab: show border only when active 2025-02-27 17:42:18 +06:00
wukko
36de546fe2
web/SidebarTab: show border only when active
& also brighten when active on mobile
2025-02-27 17:41:39 +06:00
wukko
78db8d5eef
web/SupportedServices: add hover & press states 2025-02-27 17:33:42 +06:00
wukko
2573089378
web/OmniboxIcon: spin the spinner only when it's visible
before it'd always spin in background while being invisible... which is probably not really good
2025-02-25 17:43:22 +06:00
wukko
c45c1d13c0
web/SettingsInput: validate input properly, reduce padding 2025-02-25 13:40:04 +06:00
wukko
631f8bddd8
web/FilenamePreview: reduce border, improve padding 2025-02-25 13:38:16 +06:00
wukko
ad9fd4f601
web/DownloadButton: fix 1.5px misalignment & add press state
also fixed opacity when disabled
2025-02-25 13:37:27 +06:00
wukko
20d24eca43
web/ClearButton: add missing button class 2025-02-25 13:36:07 +06:00
wukko
ceee059ecf
web/Switcher: reduce padding 2025-02-25 13:35:57 +06:00
wukko
78a4c9adbf
web/layout: better light mode colors for button states 2025-02-25 13:35:21 +06:00
wukko
0f21c9b236
web/layout: reduce button border by .5px
yes it matters a lot to me
2025-02-25 13:34:51 +06:00
wukko
104c9004c5
web/UpdateNotification: fix mobile position 2025-02-25 00:44:43 +06:00
wukko
0ae5cad2f5
web: fix PageNavTab & SidebarTab bg flicker on selection
it used to be: normal-> hover -> pressed -> hover -> active

but now it's: normal -> hover -> pressed -> active
2025-02-25 00:37:18 +06:00
wukko
24a75eaf80
web/components: add missing "button" class to main components 2025-02-25 00:17:52 +06:00
wukko
384ea412ea
web/layout: bright sidebar in light mode, content frame 2025-02-25 00:13:37 +06:00
wukko
346b9084b0
web/PageNavTab: add press state & border on hover 2025-02-24 23:52:09 +06:00
wukko
bbc7629190
web/layout: move ProcessingQueue outside of content
because it's always above content
2025-02-24 23:49:01 +06:00
wukko
137fdd8c03
web/AboutSupport: add a missing button class 2025-02-24 22:38:59 +06:00
wukko
010dfff672
web/SettingsInput: add missing button classes 2025-02-24 22:37:09 +06:00
wukko
20c45823ee
web/layout: fix dark mode button colors, proper press state for buttons 2025-02-24 22:34:00 +06:00
wukko
60f4009947
web/CobaltLogo: color the logo according to sidebar colors 2025-02-24 22:29:48 +06:00
wukko
efa09d7280
web/SettingsDropdown: remove duplicated hover declaration 2025-02-24 22:29:27 +06:00
wukko
33dd4b9fd8
web/SettingsToggle: add button class (because it's a button) 2025-02-24 22:29:05 +06:00
wukko
3e2c7a3c91
web/i18n/settings: fix video filename preview
now it displays the actual filename format you get
2025-02-24 22:28:33 +06:00
wukko
ded23ec29a
web/layout: use the chrome workaround only in chrome lol
oops
2025-02-24 21:33:24 +06:00
wukko
424a16729e
web/settings/local: update name of the media processing section 2025-02-24 18:46:11 +06:00
wukko
910e889f60
web/layout: don't use sign() in chrome cuz it's not supported there 2025-02-24 18:38:33 +06:00
wukko
5fa5a0e7cb
web/device: add browser type (just chrome for now) 2025-02-24 18:36:32 +06:00
wukko
910cbcf236
web/AboutSupport: allow the card to fill the available space 2025-02-24 17:24:06 +06:00
wukko
2e317c3abe
web/settings: update PageNav icon colors & icon for credits 2025-02-24 17:23:38 +06:00
wukko
969058d70b
web/settings: update PageNav colors 2025-02-24 17:22:56 +06:00
wukko
52528ddee8
web/PageNavTab: add more colors 2025-02-24 17:12:58 +06:00
wukko
b2df289894
web/PageNavTab: cleaner icon style 2025-02-24 16:30:33 +06:00
wukko
8e4d0cd03d
web/settings: add a local processing page 2025-02-24 15:51:11 +06:00
wukko
89fccae33d
web/settings/migrate: migrate alwaysProxy 2025-02-24 15:49:07 +06:00
wukko
b463ec7a7d
web/settings: move alwaysProxy & localProcessing, add useWebCodecs 2025-02-24 15:48:52 +06:00
wukko
540aee6194
merge: updates from main 2025-02-24 15:11:30 +06:00
wukko
dcc99f0e62
merge: 10.7.4 from main branch 2025-02-13 17:20:11 +06:00
wukko
3d98b4f9e4
web/i18n/error: update age restriction errors 2025-02-13 17:07:20 +06:00
wukko
dcc5b5d2fd
web/PickerDialog: adjust mobile scaling a bit 2025-02-13 01:05:08 +06:00
wukko
bc70cf4b6b
web/DialogHolder: improve bottom margin in mobile pwa 2025-02-13 00:53:17 +06:00
wukko
8d7f0d984f
web/layout: add & use the css variable for the focus ring 2025-02-13 00:32:02 +06:00
wukko
935947cafc
web/PickerItem: add a proper focus ring & fix different border radius 2025-02-13 00:29:09 +06:00
wukko
553b3f9091
web/PickerDialog: align the grid perfectly, better scaling 2025-02-13 00:26:45 +06:00
wukko
c0b671e45f
web/queen-bee: move runners to their own files 2025-02-12 13:34:52 +06:00
wukko
564fc65297
web/workers/remux: init libav only once, terminate after usage 2025-02-12 13:19:13 +06:00
wukko
ff62a4c2e6
web/types/libav: replace "extension" with "format" in FileInfo 2025-02-12 13:17:56 +06:00
wukko
c31c484894
merge: 10.7.3 from main 2025-02-11 16:18:30 +06:00
lostdusty
fa267ae54b
api/core: return 429 http status for rate-limit (#1066)
Co-authored-by: jj <log@riseup.net>
2025-02-11 14:42:31 +06:00
wukko
ad23b70e9d
merge: api 10.7.2 from main branch 2025-02-10 15:27:53 +06:00
wukko
fb739f5315
merge: 10.7 api from main branch 2025-02-09 18:34:30 +06:00
wukko
ce510a5746
web/layout: remove sidebar rounding on desktop 2025-02-07 18:51:06 +06:00
wukko
ca3263f1f3
web/layout: fix mobile nav bar gradient 2025-02-07 18:50:46 +06:00
wukko
adaf502d66
web: remove the early prototype of cutout functionality
at the time of this commit, there are no models that are good enough and can run in a web browser. this feature might come back when web onnx gets support for beefier models.
2025-02-07 16:55:28 +06:00
wukko
039ccf91be
web/cutout: allow opening the page without extra settings 2025-02-07 16:48:10 +06:00
wukko
95d9913e3e
web/Sidebar: always show cutout tab 2025-02-07 16:47:36 +06:00
wukko
dc33c07b39
web/storage: add clearCacheStorage function 2025-02-06 23:45:03 +06:00
wukko
1f79bf6e52
web/settings/advanced: add cache clearing, refactor data management 2025-02-06 23:44:05 +06:00
wukko
cff47da742
web/ProcessingQueue: add estimated storage usage 2025-02-06 22:56:05 +06:00
wukko
7a042e3bfa
web/ProcessingQueue: clear old files from storage on page load 2025-02-06 22:28:08 +06:00
wukko
0ce777cbfc
api/internal-hls: transform segment uri when probing the HLS tunnel 2025-02-06 14:29:42 +06:00
wukko
23f28acff0
web/i18n/error: update age-restriction & login errors 2025-02-05 19:23:29 +06:00
wukko
c8ea19a69c
web/SettingsInput: fix z-index of input inner buttons 2025-02-05 19:09:37 +06:00
wukko
4f50b44e68
web/SettingsInput: make the clear button non-destructive
clear button now clears data only in the input box, not actual data

if you accidentally clear old data and don't save it, you can restore it with one click :3
2025-02-05 19:01:30 +06:00
wukko
c5d8d33870
web/SettingsInput: hide sensitive input & allow to show it with a button
also fixed padding & svg rendering in safari
2025-02-05 18:30:00 +06:00
wukko
62dccf7b51
web/SettingsInput: hide sensitive info (such as api key) 2025-02-05 17:07:29 +06:00
wukko
88d4b4dc7c
web/ProgressBar: check if completedWorkers exists 2025-02-03 18:09:03 +06:00
wukko
1716c1d2af
web/state/queue: check if pipeline exists before removing workers 2025-02-03 18:08:47 +06:00
wukko
6c18f1d460
web/ProcessingQueueItem: fix queue scroll 2025-02-02 14:45:31 +06:00
wukko
161b3a7e3c
web/i18n/queue: update title 2025-02-02 02:28:31 +06:00
wukko
de5a2d10ca
web/SectionHeading: reduce line height for beta tag 2025-02-02 02:08:50 +06:00
wukko
12ea601e6d
web/state/queue: clean up result file when removing the task 2025-02-02 02:01:37 +06:00
wukko
c8ecf41b10
web/ProcessingQueueItem: fix stray space on error 2025-02-02 01:54:15 +06:00
wukko
945f87d93b
web/libav: allow passing options to init 2025-02-02 01:53:59 +06:00
wukko
19a342457b
web/storage: catch the missing dir error 2025-02-02 01:08:07 +06:00
wukko
61efa619a2
web/queue: fix filename on downloads, add mimetype, remove duplicates
filename is no longer passed to workers for no reason
2025-02-02 00:31:54 +06:00
wukko
50df95b212
web/queue: clear files from storage when needed 2025-02-02 00:15:44 +06:00
wukko
5464574a3e
web/workers: use opfs instead of blobs for better memory management
spent almost an entire day figuring this out but it's so worth it
2025-02-01 23:26:57 +06:00
wukko
0a8323be54
web/tsconfig: add webworker lib 2025-02-01 22:49:21 +06:00
wukko
ee459e8694
web/layout: always display processing queue
because the remux page relies on it
2025-01-31 23:59:01 +06:00
wukko
90dcc48cad
web/i18n/queue: update stub text 2025-01-31 23:54:41 +06:00
wukko
590b42a574
web/ProcessingQueueItem: fix processing-info overflow on mobile 2025-01-31 23:20:44 +06:00
wukko
ef08633bdb
web/ProcessingQueueItem: mobile css fixes 2025-01-31 23:06:17 +06:00
wukko
00d376d4ac
web/scheduler: break the global loop if current task is not done
i forgot to put break here, just blinded out that break on line 55 is breaking only its own inner loop
2025-01-31 22:08:57 +06:00
wukko
6513ab38d0
web/state/queue: clear all current tasks on queue clear 2025-01-31 22:02:35 +06:00
wukko
a7c1317af7
web/state/queue: clear pipeline results on error 2025-01-31 22:02:18 +06:00
wukko
2ae0fd01dd
web/ProcessingQueue: use full progress per item, not just running task 2025-01-31 21:59:44 +06:00
wukko
398c5402d2
web/ProcessingQueueItem: display all steps in progress bar 2025-01-31 21:59:00 +06:00
jj
cdfb6e0fd9
web: bump libav remux version 2025-01-31 11:20:54 +00:00
wukko
1590490db2
web/queue: add a remux worker to saving pipeline, use pipelineResults 2025-01-31 11:22:31 +06:00
wukko
f2325bdc24
web/workers/remux: accept several files, custom args and output 2025-01-31 11:16:04 +06:00
wukko
7caee22aee
web/scheduler: worker pipeline sequencing, file exchange between workers 2025-01-31 11:12:00 +06:00
wukko
d15f1ec8f2
web/workers/remux: differentiate remux worker file event 2025-01-30 18:58:02 +06:00
wukko
00106e9379
web/libav: accept several inputs, refactor 2025-01-30 18:48:45 +06:00
wukko
fd1a7530ed
merge: api updates from main 2025-01-30 16:47:21 +06:00
wukko
b7997c220e
web/i18n/queue: update stub text 2025-01-30 16:39:52 +06:00
wukko
5d7724762d
web: very early implementation of a fetch worker 2025-01-30 01:04:33 +06:00
wukko
affe49474d
api/readme: fix a typo in acknowledgments
an ability -> the ability
2025-01-29 16:43:12 +06:00
wukko
91f5d63b93
web/DownloadButton: extract api interaction logic into a lib
download button state is now stored, well, in a state
2025-01-29 16:35:43 +06:00
wukko
1c34d2daff
merge: docs & test updates from main 2025-01-29 15:43:51 +06:00
wukko
b6472d5406
web: update h265 & gif params, migrate old params to new names 2025-01-29 15:40:29 +06:00
wukko
3a96c8ae56
docs/api: update h265 & gif params 2025-01-29 15:38:23 +06:00
wukko
e7d4b72c8c
api/schema: tiktokH265 -> allowH265, twitterGif -> convertGif
h265 param is already used for more than tiktok, and gif param will be used for bluesky gifs in the future
2025-01-29 15:37:58 +06:00
wukko
a43e7a629b
web: add local processing setting & api type
response is not handled at all yet, this is a raw draft
2025-01-29 15:06:16 +06:00
wukko
c7c9cf2f0f
api: add local processing response type & param
`local-processing` type returns needed info for on-device processing and creates basic proxy tunnels
2025-01-29 15:00:50 +06:00
jj
75cda47633
web/libav: accept canonical extension if blob is a file 2025-01-25 20:13:23 +00:00
wukko
c5e7b29c6c
web/ProcessingStatus: fix button focus ring 2025-01-26 02:13:09 +06:00
wukko
4f2c19b680
web/ProcessingQueue: indeterminate progress state 2025-01-26 02:06:37 +06:00
jj
af18bcd43f
web/ProcessingQueue: include worker progress in global progress 2025-01-25 19:48:40 +00:00
wukko
7c3e1e6779
web/remux: remove fossil code & clean files after queue push 2025-01-26 01:40:18 +06:00
wukko
c3cc6c09f4
web/ProcessingQueueItem: state icons, localized strings, fix line break 2025-01-26 01:34:56 +06:00
wukko
73d2f45dae
web/ProcessingStatus: make the button squishy 2025-01-26 00:57:56 +06:00
wukko
de66ac6b08
web/run-worker: subscribe to queue & kill worker when removed from store
& also clear the interval
2025-01-25 23:59:45 +06:00
wukko
d4684fa1f7
web/ProcessingQueueItem: break file title line anywhere 2025-01-25 02:10:44 +06:00
wukko
1e6b1cb201
web/ProcessingQueueItem: format file size to be readable 2025-01-25 02:06:50 +06:00
wukko
44a99bdb3a
web/queue: add remuxing progress & general improvements
and a bunch of other stuff:
- size and percentage in queue
- indeterminate progress bar
- if libav wasm freezes, the worker kill itself
- cleaner states
- cleaner props
2025-01-25 01:25:53 +06:00
jj
c4c47bdc27
merge: 10.6 updates 2025-01-21 13:36:37 +00:00
wukko
192635f2ce
web/cutout: accommodate for updated file receivers 2025-01-19 03:00:03 +06:00
wukko
2279b5d845
web: core system for queue & queen bee, move remux to new system
it's 3 am and i think i had a divine intervention
2025-01-19 02:57:15 +06:00
jj
2273bb388f
web/vite: split transformers.js into separate chunk 2025-01-16 20:42:58 +00:00
wukko
8a5b25b4ce
web/removebg: fix the incorrect file condition 2025-01-17 01:51:10 +06:00
wukko
b85771dc1d
web/removebg: differentiate messaging even more, add temporary logging 2025-01-17 01:45:11 +06:00
wukko
cc3e3be118
web/cutout: fix canvas visibility 2025-01-17 01:25:52 +06:00
wukko
28eb9ebe5d
web/remux: improve page <-> worker messaging 2025-01-17 01:16:51 +06:00
wukko
8e9347b4a0
web/removebg: fix functionality after build, improve pipeline
- no longer killing the worker if it has done its job correctly and is expected to shut itself down
- no longer reading messages not intended for the worker handler and also made the cobalt messaging distnict
2025-01-17 01:03:59 +06:00
wukko
2812960088
web/cutout: reset the page state if the worker breaks 2025-01-16 13:46:52 +06:00
wukko
f544768784
web/cutout: add a button to cancel the job 2025-01-15 23:14:29 +06:00
wukko
0e26424355
web/libav: remove environment import to fix the worker 2025-01-15 22:25:59 +06:00
wukko
1ed2eef65a
web/remux: convert to a web worker (wip) 2025-01-15 22:11:08 +06:00
wukko
28d8927c08
web/removebg: convert to a proper web worker
no more hanging ui :3
2025-01-15 17:22:34 +06:00
wukko
2f2d39dc4c
web/removebg: fix types (remove garbage) 2025-01-14 18:30:33 +06:00
wukko
d649a00718
web/Sidebar: fix bottom padding on desktop 2025-01-14 18:25:43 +06:00
wukko
302ff4ff29
web/sidebar/CobaltLogo: fix padding 2025-01-14 18:21:16 +06:00
wukko
e02e7f2260
web: very early proof-of-concept of on-device image background removal 2025-01-13 01:26:54 +06:00
jj
2b95af1b51
merge: fix for tiktok audio download from picker 2025-01-12 17:14:12 +00:00
wukko
a892a37c53
web/layout: remove rounded corners on sidebar in dark theme 2025-01-12 22:58:59 +06:00
wukko
abc4673af7
web/sidebar: reduce padding on desktop & fix mobile padding 2025-01-12 22:55:10 +06:00
wukko
f816fae6ba
web/layout: increase sidebar contrast in dark theme 2025-01-12 22:49:03 +06:00
wukko
2272bb5edd
web/save: reduce terms note size on desktop 2025-01-12 22:37:49 +06:00
wukko
f0e67fb69f
web/Omnibox: reduce omnibox gap 2025-01-12 22:37:06 +06:00
wukko
c8bd08a290
web/PageNavTab: remove redundant bg 2025-01-12 19:12:41 +06:00
wukko
0749106b96
web/SidebarTab: never break the tab name line 2025-01-11 21:07:44 +06:00
wukko
4b5fd1cda0
web/PopoverContainer: fix popover z-index 2025-01-08 17:55:50 +06:00
wukko
a6069f406f
api & web: merge base queue ui & api updates 2025-01-08 17:20:00 +06:00
jj
45e7b69937
api/tunnel: add Content-Disposition to exposed headers 2024-12-25 20:05:18 +00:00
wukko
806a644a40
web/ProcessingStatus: replace icon with a more fitting one 2024-12-22 23:10:33 +06:00
wukko
41600dab4f
web/settings/advanced: add a toggle for local processing 2024-12-22 23:04:37 +06:00
wukko
a9515d376a
web/settings: add duck to settings types 2024-12-22 23:04:20 +06:00
jj
52b7f9523f
api/stream: remove content-length estimation from proxy() 2024-12-20 16:35:40 +00:00
jj
78d0670f50
api/stream: stfu deepsource 2024-12-17 12:20:17 +00:00
jj
06c348126e
api/stream: remove random undici import
wtf
2024-12-17 12:16:04 +00:00
jj
fec07d0e10
api: add cors headers for tunnels 2024-12-16 17:45:02 +00:00
jj
f5b47a2b7e
api/tunnel: adjust estimate multiplier to 1.1 2024-12-16 17:42:39 +00:00
jj
6e6a792984
api/bilibili: mark tunnel as isHLS where appropriate 2024-12-16 17:41:38 +00:00
jj
05e0f031ed
api/stream: add Estimated-Content-Length header to tunnels
present where Content-Length cannot be accurately calculated,
pure proxy streams do not have this header and instead have
the accurate Content-Length one.
2024-12-16 17:07:30 +00:00
jj
11388cb418
api/stream: await all call types 2024-12-16 16:21:38 +00:00
jj
bf4675a5e3
api/stream: move bsky override into isHlsResponse 2024-12-16 11:29:13 +00:00
jj
bc597c817f
api: move itunnel handlers to separate file 2024-12-16 10:38:31 +00:00
jj
f06aa65801
api: always create separate server for itunnels 2024-12-16 10:19:15 +00:00
jj
e7c2872e40
api/stream: rename getInternalStream to getInternalTunnel 2024-12-16 10:16:48 +00:00
wukko
5820736a31
web/ProcessingQueue: use the heading component with the beta tag 2024-12-19 21:11:02 +06:00
wukko
06000cbc77
web/SectionHeading: added a new prop to disable the link 2024-12-19 21:09:51 +06:00
wukko
8c9f7ff36d
web/ProcessingQueue: align buttons to center vertically 2024-12-18 18:42:34 +06:00
wukko
73d0b24aaf
web/layout: move processing queue into content for better a11y 2024-12-18 17:57:07 +06:00
wukko
5860efa620
web/PopoverContainer: hide for screen readers when not expanded 2024-12-18 17:48:40 +06:00
wukko
f3ff3656ef
web/ProcessingQueue: fix ui on narrow screens 2024-12-18 17:47:48 +06:00
wukko
eba8dc3767
web/ProcessingQueue: make the clear button actually clear the queue 2024-12-18 17:10:30 +06:00
wukko
3f46395bd2
web/state/queue: add nukeEntireQueue() 2024-12-18 17:10:08 +06:00
wukko
a8bb64ffb1
web/ProcessingQueue: use new types and states, refactor
- added a dedicated ui debug button
- fixed scrolling (content is no longer cutting off weirdly)
- moved stub to its own component
- moved all permanent strings to localization
2024-12-18 17:04:57 +06:00
wukko
13ec4f4faf
web/queue: add types & states 2024-12-18 16:59:08 +06:00
wukko
fcab598ec4
web/ProcessingStatus: make the icon thinner 2024-12-18 16:58:26 +06:00
wukko
11e3d7a8f4
web: rename DownloadManager to ProcessingQueue
also replaced the download icon with a blender (to be updated, maybe)
2024-12-17 16:50:13 +06:00
wukko
13c4438a57
web/DownloadManager: item component & type 2024-12-17 01:25:02 +06:00
wukko
45434ba751
web/UpdateNotification: accommodate space for the download manager 2024-12-16 18:05:39 +06:00
wukko
6d0ec5dd85
web: basic ui for the download queue manager 2024-12-16 18:03:55 +06:00
wukko
5d75ee493d
web/SupportedServices: use the general popover component 2024-12-16 17:24:05 +06:00
wukko
91327220a0
web/PopoverContainer: create a reusable popover component 2024-12-16 17:23:43 +06:00
142 changed files with 3779 additions and 1230 deletions

View File

@ -71,7 +71,7 @@ as long as you:
## open source acknowledgements ## open source acknowledgements
### ffmpeg ### ffmpeg
cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have an ability to use it for free, just like anyone else. we believe it should be way more recognized. cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have the ability to use it for free, just like anyone else. we believe it should be way more recognized.
you can [support ffmpeg here](https://ffmpeg.org/donations.html)! you can [support ffmpeg here](https://ffmpeg.org/donations.html)!

View File

@ -1,7 +1,7 @@
{ {
"name": "@imput/cobalt-api", "name": "@imput/cobalt-api",
"description": "save what you love", "description": "save what you love",
"version": "10.8.2", "version": "10.7.10",
"author": "imput", "author": "imput",
"exports": "./src/cobalt.js", "exports": "./src/cobalt.js",
"type": "module", "type": "module",
@ -11,6 +11,7 @@
"scripts": { "scripts": {
"start": "node src/cobalt", "start": "node src/cobalt",
"test": "node src/util/test", "test": "node src/util/test",
"token:youtube": "node src/util/generate-youtube-tokens",
"token:jwt": "node src/util/generate-jwt-secret" "token:jwt": "node src/util/generate-jwt-secret"
}, },
"repository": { "repository": {
@ -34,11 +35,12 @@
"ffmpeg-static": "^5.1.0", "ffmpeg-static": "^5.1.0",
"hls-parser": "^0.10.7", "hls-parser": "^0.10.7",
"ipaddr.js": "2.2.0", "ipaddr.js": "2.2.0",
"mime": "^4.0.4",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",
"set-cookie-parser": "2.6.0", "set-cookie-parser": "2.6.0",
"undici": "^5.19.1", "undici": "^5.19.1",
"url-pattern": "1.0.3", "url-pattern": "1.0.3",
"youtubei.js": "^13.2.0", "youtubei.js": "^13.1.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -53,11 +53,7 @@ const env = {
keyReloadInterval: 900, keyReloadInterval: 900,
enabledServices, enabledServices,
customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT, customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
ytSessionServer: process.env.YOUTUBE_SESSION_SERVER,
ytSessionReloadInterval: 300,
ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
} }
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";

View File

@ -8,20 +8,19 @@ import jwt from "../security/jwt.js";
import stream from "../stream/stream.js"; import stream from "../stream/stream.js";
import match from "../processing/match.js"; import match from "../processing/match.js";
import { env, isCluster, setTunnelPort } from "../config.js"; import { env } from "../config.js";
import { extract } from "../processing/url.js"; import { extract } from "../processing/url.js";
import { Green, Bright, Cyan } from "../misc/console-text.js"; import { Bright, Cyan } from "../misc/console-text.js";
import { hashHmac } from "../security/secrets.js"; import { hashHmac } from "../security/secrets.js";
import { createStore } from "../store/redis-ratelimit.js"; import { createStore } from "../store/redis-ratelimit.js";
import { randomizeCiphers } from "../misc/randomize-ciphers.js"; import { randomizeCiphers } from "../misc/randomize-ciphers.js";
import { verifyTurnstileToken } from "../security/turnstile.js"; import { verifyTurnstileToken } from "../security/turnstile.js";
import { friendlyServiceName } from "../processing/service-alias.js"; import { friendlyServiceName } from "../processing/service-alias.js";
import { verifyStream, getInternalStream } from "../stream/manage.js"; import { verifyStream } from "../stream/manage.js";
import { createResponse, normalizeRequest, getIP } from "../processing/request.js"; import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
import * as APIKeys from "../security/api-keys.js"; import * as APIKeys from "../security/api-keys.js";
import * as Cookies from "../processing/cookie/manager.js"; import * as Cookies from "../processing/cookie/manager.js";
import * as YouTubeSession from "../processing/helpers/youtube-session.js"; import { setupTunnelHandler } from "./itunnel.js";
const git = { const git = {
branch: await getBranch(), branch: await getBranch(),
@ -62,13 +61,13 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
}) })
const handleRateExceeded = (_, res) => { const handleRateExceeded = (_, res) => {
const { status, body } = createResponse("error", { const { body } = createResponse("error", {
code: "error.api.rate_exceeded", code: "error.api.rate_exceeded",
context: { context: {
limit: env.rateLimitWindow limit: env.rateLimitWindow
} }
}); });
return res.status(status).json(body); return res.status(429).json(body);
}; };
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url'); const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
@ -265,6 +264,15 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
} }
}) })
app.use('/tunnel', cors({
methods: ['GET'],
exposedHeaders: [
'Estimated-Content-Length',
'Content-Disposition'
],
...corsConfig,
}));
app.get('/tunnel', apiTunnelLimiter, async (req, res) => { app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
const id = String(req.query.id); const id = String(req.query.id);
const exp = String(req.query.exp); const exp = String(req.query.exp);
@ -294,31 +302,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
} }
return stream(res, streamInfo); return stream(res, streamInfo);
}) });
const itunnelHandler = (req, res) => {
if (!req.ip.endsWith('127.0.0.1')) {
return res.sendStatus(403);
}
if (String(req.query.id).length !== 21) {
return res.sendStatus(400);
}
const streamInfo = getInternalStream(req.query.id);
if (!streamInfo) {
return res.sendStatus(404);
}
streamInfo.headers = new Map([
...(streamInfo.headers || []),
...Object.entries(req.headers)
]);
return stream(res, { type: 'internal', data: streamInfo });
};
app.get('/itunnel', itunnelHandler);
app.get('/', (_, res) => { app.get('/', (_, res) => {
res.type('json'); res.type('json');
@ -356,7 +340,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
}, () => { }, () => {
if (isPrimary) { if (isPrimary) {
console.log(`\n` + console.log(`\n` +
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" + Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
"~~~~~~\n" + "~~~~~~\n" +
Bright("version: ") + version + "\n" + Bright("version: ") + version + "\n" +
@ -378,23 +362,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
if (env.cookiePath) { if (env.cookiePath) {
Cookies.setup(env.cookiePath); Cookies.setup(env.cookiePath);
} }
if (env.ytSessionServer) {
YouTubeSession.setup();
}
}); });
if (isCluster) { setupTunnelHandler();
const istreamer = express();
istreamer.get('/itunnel', itunnelHandler);
const server = istreamer.listen({
port: 0,
host: '127.0.0.1',
exclusive: true
}, () => {
const { port } = server.address();
console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
setTunnelPort(port);
});
}
} }

61
api/src/core/itunnel.js Normal file
View File

@ -0,0 +1,61 @@
import stream from "../stream/stream.js";
import { getInternalTunnel } from "../stream/manage.js";
import { setTunnelPort } from "../config.js";
import { Green } from "../misc/console-text.js";
import express from "express";
const validateTunnel = (req, res) => {
if (!req.ip.endsWith('127.0.0.1')) {
res.sendStatus(403);
return;
}
if (String(req.query.id).length !== 21) {
res.sendStatus(400);
return;
}
const streamInfo = getInternalTunnel(req.query.id);
if (!streamInfo) {
res.sendStatus(404);
return;
}
return streamInfo;
}
const streamTunnel = (req, res) => {
const streamInfo = validateTunnel(req, res);
if (!streamInfo) {
return;
}
streamInfo.headers = new Map([
...(streamInfo.headers || []),
...Object.entries(req.headers)
]);
return stream(res, { type: 'internal', data: streamInfo });
}
export const setupTunnelHandler = () => {
const tunnelHandler = express();
tunnelHandler.get('/itunnel', streamTunnel);
// fallback
tunnelHandler.use((_, res) => res.sendStatus(400));
// error handler
tunnelHandler.use((_, __, res, ____) => res.socket.end());
const server = tunnelHandler.listen({
port: 0,
host: '127.0.0.1',
exclusive: true
}, () => {
const { port } = server.address();
console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`);
setTunnelPort(port);
});
}

View File

@ -13,6 +13,7 @@ const VALID_SERVICES = new Set([
'reddit', 'reddit',
'twitter', 'twitter',
'youtube', 'youtube',
'youtube_oauth'
]); ]);
const invalidCookies = {}; const invalidCookies = {};

View File

@ -1,74 +0,0 @@
import * as cluster from "../../misc/cluster.js";
import { env } from "../../config.js";
import { Green, Yellow } from "../../misc/console-text.js";
let session;
const validateSession = (sessionResponse) => {
if (!sessionResponse.potoken) {
throw "no poToken in session response";
}
if (!sessionResponse.visitor_data) {
throw "no visitor_data in session response";
}
if (!sessionResponse.updated) {
throw "no last update timestamp in session response";
}
// https://github.com/iv-org/youtube-trusted-session-generator/blob/c2dfe3f/potoken_generator/main.py#L25
if (sessionResponse.potoken.length < 160) {
console.error(`${Yellow('[!]')} poToken is too short and might not work (${new Date().toISOString()})`);
}
}
const updateSession = (newSession) => {
session = newSession;
}
const loadSession = async () => {
const sessionServerUrl = new URL(env.ytSessionServer);
sessionServerUrl.pathname = "/token";
const newSession = await fetch(sessionServerUrl).then(a => a.json());
validateSession(newSession);
if (!session || session.updated < newSession?.updated) {
cluster.broadcast({ youtube_session: newSession });
updateSession(newSession);
}
}
const wrapLoad = (initial = false) => {
loadSession()
.then(() => {
if (initial) {
console.log(`${Green('[✓]')} poToken & visitor_data loaded successfully!`);
}
})
.catch((e) => {
console.error(`${Yellow('[!]')} Failed loading poToken & visitor_data at ${new Date().toISOString()}.`);
console.error('Error:', e);
})
}
export const getYouTubeSession = () => {
return session;
}
export const setup = () => {
if (cluster.isPrimary) {
wrapLoad(true);
if (env.ytSessionReloadInterval > 0) {
setInterval(wrapLoad, env.ytSessionReloadInterval * 1000);
}
} else if (cluster.isWorker) {
process.on('message', (message) => {
if ('youtube_session' in message) {
updateSession(message.youtube_session);
}
});
}
}

View File

@ -5,7 +5,22 @@ import { audioIgnore } from "./service-config.js";
import { createStream } from "../stream/manage.js"; import { createStream } from "../stream/manage.js";
import { splitFilenameExtension } from "../misc/utils.js"; import { splitFilenameExtension } from "../misc/utils.js";
export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) { const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"];
export default function({
r,
host,
audioFormat,
isAudioOnly,
isAudioMuted,
disableMetadata,
filenameStyle,
convertGif,
requestIP,
audioBitrate,
alwaysProxy,
localProcessing
}) {
let action, let action,
responseType = "tunnel", responseType = "tunnel",
defaultParams = { defaultParams = {
@ -22,7 +37,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
if (r.isPhoto) action = "photo"; if (r.isPhoto) action = "photo";
else if (r.picker) action = "picker" else if (r.picker) action = "picker"
else if (r.isGif && twitterGif) action = "gif"; else if (r.isGif && convertGif) action = "gif";
else if (isAudioOnly) action = "audio"; else if (isAudioOnly) action = "audio";
else if (isAudioMuted) action = "muteVideo"; else if (isAudioMuted) action = "muteVideo";
else if (r.isHLS) action = "hls"; else if (r.isHLS) action = "hls";
@ -216,5 +231,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
params.type = "proxy"; params.type = "proxy";
} }
return createResponse(responseType, {...defaultParams, ...params}) // TODO: add support for HLS
// (very painful)
if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) {
responseType = "local-processing";
}
return createResponse(
responseType,
{ ...defaultParams, ...params }
);
} }

View File

@ -70,7 +70,7 @@ export default async function({ host, patternMatch, params }) {
r = await twitter({ r = await twitter({
id: patternMatch.id, id: patternMatch.id,
index: patternMatch.index - 1, index: patternMatch.index - 1,
toGif: !!params.twitterGif, toGif: !!params.convertGif,
alwaysProxy: params.alwaysProxy, alwaysProxy: params.alwaysProxy,
dispatcher dispatcher
}); });
@ -109,7 +109,7 @@ export default async function({ host, patternMatch, params }) {
} }
if (url.hostname === "music.youtube.com" || isAudioOnly) { if (url.hostname === "music.youtube.com" || isAudioOnly) {
fetchInfo.quality = "1080"; fetchInfo.quality = "max";
fetchInfo.format = "vp9"; fetchInfo.format = "vp9";
fetchInfo.isAudioOnly = true; fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false; fetchInfo.isAudioMuted = false;
@ -131,7 +131,7 @@ export default async function({ host, patternMatch, params }) {
shortLink: patternMatch.shortLink, shortLink: patternMatch.shortLink,
fullAudio: params.tiktokFullAudio, fullAudio: params.tiktokFullAudio,
isAudioOnly, isAudioOnly,
h265: params.tiktokH265, h265: params.allowH265,
alwaysProxy: params.alwaysProxy, alwaysProxy: params.alwaysProxy,
}); });
break; break;
@ -243,7 +243,7 @@ export default async function({ host, patternMatch, params }) {
case "xiaohongshu": case "xiaohongshu":
r = await xiaohongshu({ r = await xiaohongshu({
...patternMatch, ...patternMatch,
h265: params.tiktokH265, h265: params.allowH265,
isAudioOnly, isAudioOnly,
dispatcher, dispatcher,
}); });
@ -300,10 +300,11 @@ export default async function({ host, patternMatch, params }) {
isAudioMuted, isAudioMuted,
disableMetadata: params.disableMetadata, disableMetadata: params.disableMetadata,
filenameStyle: params.filenameStyle, filenameStyle: params.filenameStyle,
twitterGif: params.twitterGif, convertGif: params.convertGif,
requestIP, requestIP,
audioBitrate: params.audioBitrate, audioBitrate: params.audioBitrate,
alwaysProxy: params.alwaysProxy, alwaysProxy: params.alwaysProxy,
localProcessing: params.localProcessing,
}) })
} catch { } catch {
return createResponse("error", { return createResponse("error", {

View File

@ -1,7 +1,8 @@
import mime from "mime";
import ipaddr from "ipaddr.js"; import ipaddr from "ipaddr.js";
import { createStream } from "../stream/manage.js";
import { apiSchema } from "./schema.js"; import { apiSchema } from "./schema.js";
import { createProxyTunnels, createStream } from "../stream/manage.js";
export function createResponse(responseType, responseData) { export function createResponse(responseType, responseData) {
const internalError = (code) => { const internalError = (code) => {
@ -49,6 +50,41 @@ export function createResponse(responseType, responseData) {
} }
break; break;
case "local-processing":
response = {
type: responseData?.type,
service: responseData?.service,
tunnel: createProxyTunnels(responseData),
output: {
type: mime.getType(responseData?.filename) || undefined,
filename: responseData?.filename,
metadata: responseData?.fileMetadata || undefined,
},
audio: {
copy: responseData?.audioCopy,
format: responseData?.audioFormat,
bitrate: responseData?.audioBitrate,
},
isHLS: responseData?.isHLS,
}
if (!response.audio.format) {
if (response.type === "audio") {
// audio response without a format is invalid
return internalError();
}
delete response.audio;
}
if (!response.output.type || !response.output.filename) {
// response without a type or filename is invalid
return internalError();
}
break;
case "picker": case "picker":
response = { response = {
picker: responseData?.picker, picker: responseData?.picker,

View File

@ -36,15 +36,14 @@ export const apiSchema = z.object({
.regex(/^[0-9a-zA-Z\-]+$/) .regex(/^[0-9a-zA-Z\-]+$/)
.optional(), .optional(),
// TODO: remove this variable as it's no longer used disableMetadata: z.boolean().default(false),
// and is kept for schema compatibility reasons
youtubeDubBrowserLang: z.boolean().default(false), allowH265: z.boolean().default(false),
convertGif: z.boolean().default(true),
tiktokFullAudio: z.boolean().default(false),
alwaysProxy: z.boolean().default(false), alwaysProxy: z.boolean().default(false),
disableMetadata: z.boolean().default(false), localProcessing: z.boolean().default(false),
tiktokFullAudio: z.boolean().default(false),
tiktokH265: z.boolean().default(false),
twitterGif: z.boolean().default(true),
youtubeHLS: z.boolean().default(false), youtubeHLS: z.boolean().default(false),
}) })

View File

@ -47,7 +47,8 @@ async function com_download(id) {
return { return {
urls: [video.baseUrl, audio.baseUrl], urls: [video.baseUrl, audio.baseUrl],
audioFilename: `bilibili_${id}_audio`, audioFilename: `bilibili_${id}_audio`,
filename: `bilibili_${id}_${video.width}x${video.height}.mp4` filename: `bilibili_${id}_${video.width}x${video.height}.mp4`,
isHLS: true
}; };
} }

View File

@ -4,8 +4,7 @@ import { fetch } from "undici";
import { Innertube, Session } from "youtubei.js"; import { Innertube, Session } from "youtubei.js";
import { env } from "../../config.js"; import { env } from "../../config.js";
import { getCookie } from "../cookie/manager.js"; import { getCookie, updateCookieValues } from "../cookie/manager.js";
import { getYouTubeSession } from "../helpers/youtube-session.js";
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
@ -46,26 +45,41 @@ const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDR
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320]; const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
const cloneInnertube = async (customFetch, useSession) => { const transformSessionData = (cookie) => {
if (!cookie)
return;
const values = { ...cookie.values() };
const REQUIRED_VALUES = ['access_token', 'refresh_token'];
if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
return;
}
if (values.expires) {
values.expiry_date = values.expires;
delete values.expires;
} else if (!values.expiry_date) {
return;
}
return values;
}
const cloneInnertube = async (customFetch) => {
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date(); const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
const rawCookie = getCookie('youtube'); const rawCookie = getCookie('youtube');
const rawCookieValues = rawCookie?.values();
const cookie = rawCookie?.toString(); const cookie = rawCookie?.toString();
const sessionTokens = getYouTubeSession();
const retrieve_player = Boolean(sessionTokens || cookie);
if (useSession && env.ytSessionServer && !sessionTokens?.potoken) {
throw "no_session_tokens";
}
if (!innertube || shouldRefreshPlayer) { if (!innertube || shouldRefreshPlayer) {
innertube = await Innertube.create({ innertube = await Innertube.create({
fetch: customFetch, fetch: customFetch,
retrieve_player, retrieve_player: !!cookie,
cookie, cookie,
po_token: useSession ? sessionTokens?.potoken : undefined, po_token: rawCookieValues?.po_token,
visitor_data: useSession ? sessionTokens?.visitor_data : undefined, visitor_data: rawCookieValues?.visitor_data,
}); });
lastRefreshedAt = +new Date(); lastRefreshedAt = +new Date();
} }
@ -81,62 +95,73 @@ const cloneInnertube = async (customFetch, useSession) => {
innertube.session.cache innertube.session.cache
); );
const oauthCookie = getCookie('youtube_oauth');
const oauthData = transformSessionData(oauthCookie);
if (!session.logged_in && oauthData) {
await session.oauth.init(oauthData);
session.logged_in = true;
}
if (session.logged_in && oauthData) {
if (session.oauth.shouldRefreshToken()) {
await session.oauth.refreshAccessToken();
}
const cookieValues = oauthCookie.values();
const oldExpiry = new Date(cookieValues.expiry_date);
const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
if (oldExpiry.getTime() !== newExpiry.getTime()) {
updateCookieValues(oauthCookie, {
...session.oauth.client_id,
...session.oauth.oauth2_tokens,
expiry_date: newExpiry.toISOString()
});
}
}
const yt = new Innertube(session); const yt = new Innertube(session);
return yt; return yt;
} }
export default async function (o) { export default async function (o) {
const quality = o.quality === "max" ? 9000 : Number(o.quality);
let useHLS = o.youtubeHLS;
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
// HLS playlists from the iOS client don't contain the av1 video format.
if (useHLS && o.format === "av1") {
useHLS = false;
}
if (useHLS) {
innertubeClient = "IOS";
}
// iOS client doesn't have adaptive formats of resolution >1080p,
// so we use the WEB_EMBEDDED client instead for those cases
const useSession =
env.ytSessionServer && (
(
!useHLS
&& innertubeClient === "IOS"
&& (
(quality > 1080 && o.format !== "h264")
|| (quality > 1080 && o.format !== "vp9")
)
)
);
if (useSession) {
innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
}
let yt; let yt;
try { try {
yt = await cloneInnertube( yt = await cloneInnertube(
(input, init) => fetch(input, { (input, init) => fetch(input, {
...init, ...init,
dispatcher: o.dispatcher dispatcher: o.dispatcher
}), })
useSession
); );
} catch (e) { } catch (e) {
if (e === "no_session_tokens") { if (e.message?.endsWith("decipher algorithm")) {
return { error: "youtube.no_session_tokens" };
} else if (e.message?.endsWith("decipher algorithm")) {
return { error: "youtube.decipher" } return { error: "youtube.decipher" }
} else if (e.message?.includes("refresh access token")) { } else if (e.message?.includes("refresh access token")) {
return { error: "youtube.token_expired" } return { error: "youtube.token_expired" }
} else throw e; } else throw e;
} }
const cookie = getCookie('youtube')?.toString();
let useHLS = o.youtubeHLS;
// HLS playlists don't contain the av1 video format, at least with the iOS client
if (useHLS && o.format === "av1") {
useHLS = false;
}
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "ANDROID";
if (cookie) {
useHLS = false;
innertubeClient = "WEB";
}
if (useHLS) {
innertubeClient = "IOS";
}
let info; let info;
try { try {
info = await yt.getBasicInfo(o.id, innertubeClient); info = await yt.getBasicInfo(o.id, innertubeClient);
@ -215,6 +240,8 @@ export default async function (o) {
} }
} }
const quality = o.quality === "max" ? 9000 : Number(o.quality);
const normalizeQuality = res => { const normalizeQuality = res => {
const shortestSide = Math.min(res.height, res.width); const shortestSide = Math.min(res.height, res.width);
return videoQualities.find(qual => qual >= shortestSide); return videoQualities.find(qual => qual >= shortestSide);

View File

@ -1,5 +1,6 @@
import HLS from "hls-parser"; import HLS from "hls-parser";
import { createInternalStream } from "./manage.js"; import { createInternalStream } from "./manage.js";
import { request } from "undici";
function getURL(url) { function getURL(url) {
try { try {
@ -55,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"]; const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
export function isHlsResponse (req) { export function isHlsResponse(req, streamInfo) {
return HLS_MIME_TYPES.includes(req.headers['content-type']); return HLS_MIME_TYPES.includes(req.headers['content-type'])
// bluesky's cdn responds with wrong content-type for the hls playlist,
// so we enforce it here until they fix it
|| (streamInfo.service === 'bsky' && streamInfo.url.endsWith('.m3u8'));
} }
export async function handleHlsPlaylist(streamInfo, req, res) { export async function handleHlsPlaylist(streamInfo, req, res) {
@ -71,3 +75,67 @@ export async function handleHlsPlaylist(streamInfo, req, res) {
res.send(hlsPlaylist); res.send(hlsPlaylist);
} }
async function getSegmentSize(url, config) {
const segmentResponse = await request(url, {
...config,
throwOnError: true
});
if (segmentResponse.headers['content-length']) {
segmentResponse.body.dump();
return +segmentResponse.headers['content-length'];
}
// if the response does not have a content-length
// header, we have to compute it ourselves
let size = 0;
for await (const data of segmentResponse.body) {
size += data.length;
}
return size;
}
export async function probeInternalHLSTunnel(streamInfo) {
const { url, headers, dispatcher, signal } = streamInfo;
// remove all falsy headers
Object.keys(headers).forEach(key => {
if (!headers[key]) delete headers[key];
});
const config = { headers, dispatcher, signal, maxRedirections: 16 };
const manifestResponse = await fetch(url, config);
const manifest = HLS.parse(await manifestResponse.text());
if (manifest.segments.length === 0)
return -1;
const segmentSamples = await Promise.all(
Array(5).fill().map(async () => {
const manifestIdx = Math.floor(Math.random() * manifest.segments.length);
const randomSegment = manifest.segments[manifestIdx];
if (!randomSegment.uri)
throw "segment is missing URI";
let segmentUrl;
if (getURL(randomSegment.uri)) {
segmentUrl = new URL(randomSegment.uri);
} else {
segmentUrl = new URL(randomSegment.uri, streamInfo.url);
}
const segmentSize = await getSegmentSize(segmentUrl, config) / randomSegment.duration;
return segmentSize;
})
);
const averageBitrate = segmentSamples.reduce((a, b) => a + b) / segmentSamples.length;
const totalDuration = manifest.segments.reduce((acc, segment) => acc + segment.duration, 0);
return averageBitrate * totalDuration;
}

View File

@ -1,7 +1,7 @@
import { request } from "undici"; import { request } from "undici";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { closeRequest, getHeaders, pipe } from "./shared.js"; import { closeRequest, getHeaders, pipe } from "./shared.js";
import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js"; import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js";
const CHUNK_SIZE = BigInt(8e6); // 8 MB const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b; const min = (a, b) => a < b ? a : b;
@ -118,10 +118,7 @@ async function handleGenericStream(streamInfo, res) {
res.status(fileResponse.statusCode); res.status(fileResponse.statusCode);
fileResponse.body.on('error', () => {}); fileResponse.body.on('error', () => {});
// bluesky's cdn responds with wrong content-type for the hls playlist, const isHls = isHlsResponse(fileResponse, streamInfo);
// so we enforce it here until they fix it
const isHls = isHlsResponse(fileResponse)
|| (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8'));
for (const [ name, value ] of Object.entries(fileResponse.headers)) { for (const [ name, value ] of Object.entries(fileResponse.headers)) {
if (!isHls || name.toLowerCase() !== 'content-length') { if (!isHls || name.toLowerCase() !== 'content-length') {
@ -155,3 +152,40 @@ export function internalStream(streamInfo, res) {
return handleGenericStream(streamInfo, res); return handleGenericStream(streamInfo, res);
} }
export async function probeInternalTunnel(streamInfo) {
try {
const signal = AbortSignal.timeout(3000);
const headers = {
...Object.fromEntries(streamInfo.headers || []),
...getHeaders(streamInfo.service),
host: undefined,
range: undefined
};
if (streamInfo.isHLS) {
return probeInternalHLSTunnel({
...streamInfo,
signal,
headers
});
}
const response = await request(streamInfo.url, {
method: 'HEAD',
headers,
dispatcher: streamInfo.dispatcher,
signal,
maxRedirections: 16
});
if (response.statusCode !== 200)
throw "status is not 200 OK";
const size = +response.headers['content-length'];
if (isNaN(size))
throw "content-length is not a number";
return size;
} catch {}
}

View File

@ -70,10 +70,47 @@ export function createStream(obj) {
return streamLink.toString(); return streamLink.toString();
} }
export function getInternalStream(id) { export function createProxyTunnels(info) {
const proxyTunnels = [];
let urls = info.url;
if (typeof urls === "string") {
urls = [urls];
}
for (const url of urls) {
proxyTunnels.push(
createStream({
url,
type: "proxy",
service: info?.service,
headers: info?.headers,
requestIP: info?.requestIP,
originalRequest: info?.originalRequest
})
);
}
return proxyTunnels;
}
export function getInternalTunnel(id) {
return internalStreamCache.get(id); return internalStreamCache.get(id);
} }
export function getInternalTunnelFromURL(url) {
url = new URL(url);
if (url.hostname !== '127.0.0.1') {
return;
}
const id = url.searchParams.get('id');
return getInternalTunnel(id);
}
export function createInternalStream(url, obj = {}) { export function createInternalStream(url, obj = {}) {
assert(typeof url === 'string'); assert(typeof url === 'string');
@ -131,7 +168,7 @@ export function destroyInternalStream(url) {
const id = getInternalTunnelId(url); const id = getInternalTunnelId(url);
if (internalStreamCache.has(id)) { if (internalStreamCache.has(id)) {
closeRequest(getInternalStream(id)?.controller); closeRequest(getInternalTunnel(id)?.controller);
internalStreamCache.delete(id); internalStreamCache.delete(id);
} }
} }

View File

@ -1,5 +1,7 @@
import { genericUserAgent } from "../config.js"; import { genericUserAgent } from "../config.js";
import { vkClientAgent } from "../processing/services/vk.js"; import { vkClientAgent } from "../processing/services/vk.js";
import { getInternalTunnelFromURL } from "./manage.js";
import { probeInternalTunnel } from "./internal.js";
const defaultHeaders = { const defaultHeaders = {
'user-agent': genericUserAgent 'user-agent': genericUserAgent
@ -47,3 +49,40 @@ export function pipe(from, to, done) {
from.pipe(to); from.pipe(to);
} }
export async function estimateTunnelLength(streamInfo, multiplier = 1.1) {
let urls = streamInfo.urls;
if (!Array.isArray(urls)) {
urls = [ urls ];
}
const internalTunnels = urls.map(getInternalTunnelFromURL);
if (internalTunnels.some(t => !t))
return -1;
const sizes = await Promise.all(internalTunnels.map(probeInternalTunnel));
const estimatedSize = sizes.reduce(
// if one of the sizes is missing, let's just make a very
// bold guess that it's the same size as the existing one
(acc, cur) => cur <= 0 ? acc * 2 : acc + cur,
0
);
if (isNaN(estimatedSize) || estimatedSize <= 0) {
return -1;
}
return Math.floor(estimatedSize * multiplier);
}
export function estimateAudioMultiplier(streamInfo) {
if (streamInfo.audioFormat === 'wav') {
return 1411 / 128;
}
if (streamInfo.audioCopy) {
return 1;
}
return streamInfo.audioBitrate / 128;
}

View File

@ -10,20 +10,20 @@ export default async function(res, streamInfo) {
return await stream.proxy(streamInfo, res); return await stream.proxy(streamInfo, res);
case "internal": case "internal":
return internalStream(streamInfo.data, res); return await internalStream(streamInfo.data, res);
case "merge": case "merge":
return stream.merge(streamInfo, res); return await stream.merge(streamInfo, res);
case "remux": case "remux":
case "mute": case "mute":
return stream.remux(streamInfo, res); return await stream.remux(streamInfo, res);
case "audio": case "audio":
return stream.convertAudio(streamInfo, res); return await stream.convertAudio(streamInfo, res);
case "gif": case "gif":
return stream.convertGif(streamInfo, res); return await stream.convertGif(streamInfo, res);
} }
closeResponse(res); closeResponse(res);

View File

@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header";
import { env } from "../config.js"; import { env } from "../config.js";
import { destroyInternalStream } from "./manage.js"; import { destroyInternalStream } from "./manage.js";
import { hlsExceptions } from "../processing/service-config.js"; import { hlsExceptions } from "../processing/service-config.js";
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js"; import { getHeaders, closeRequest, closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
const ffmpegArgs = { const ffmpegArgs = {
webm: ["-c:v", "copy", "-c:a", "copy"], webm: ["-c:v", "copy", "-c:a", "copy"],
@ -29,7 +29,7 @@ const convertMetadataToFFmpeg = (metadata) => {
for (const [ name, value ] of Object.entries(metadata)) { for (const [ name, value ] of Object.entries(metadata)) {
if (metadataTags.includes(name)) { if (metadataTags.includes(name)) {
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004
} else { } else {
throw `${name} metadata tag is not supported.`; throw `${name} metadata tag is not supported.`;
} }
@ -98,7 +98,7 @@ const proxy = async (streamInfo, res) => {
} }
} }
const merge = (streamInfo, res) => { const merge = async (streamInfo, res) => {
let process; let process;
const shutdown = () => ( const shutdown = () => (
killProcess(process), killProcess(process),
@ -112,7 +112,7 @@ const merge = (streamInfo, res) => {
try { try {
if (streamInfo.urls.length !== 2) return shutdown(); if (streamInfo.urls.length !== 2) return shutdown();
const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]; const format = streamInfo.filename.split('.').pop();
let args = [ let args = [
'-loglevel', '-8', '-loglevel', '-8',
@ -152,6 +152,7 @@ const merge = (streamInfo, res) => {
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
pipe(muxOutput, res, shutdown); pipe(muxOutput, res, shutdown);
@ -162,7 +163,7 @@ const merge = (streamInfo, res) => {
} }
} }
const remux = (streamInfo, res) => { const remux = async (streamInfo, res) => {
let process; let process;
const shutdown = () => ( const shutdown = () => (
killProcess(process), killProcess(process),
@ -196,7 +197,7 @@ const remux = (streamInfo, res) => {
args.push('-bsf:a', 'aac_adtstoasc'); args.push('-bsf:a', 'aac_adtstoasc');
} }
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]; let format = streamInfo.filename.split('.').pop();
if (format === "mp4") { if (format === "mp4") {
args.push('-movflags', 'faststart+frag_keyframe+empty_moov') args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
} }
@ -215,6 +216,7 @@ const remux = (streamInfo, res) => {
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
pipe(muxOutput, res, shutdown); pipe(muxOutput, res, shutdown);
@ -225,7 +227,7 @@ const remux = (streamInfo, res) => {
} }
} }
const convertAudio = (streamInfo, res) => { const convertAudio = async (streamInfo, res) => {
let process; let process;
const shutdown = () => ( const shutdown = () => (
killProcess(process), killProcess(process),
@ -284,6 +286,13 @@ const convertAudio = (streamInfo, res) => {
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader(
'Estimated-Content-Length',
await estimateTunnelLength(
streamInfo,
estimateAudioMultiplier(streamInfo) * 1.1
)
);
pipe(muxOutput, res, shutdown); pipe(muxOutput, res, shutdown);
res.on('finish', shutdown); res.on('finish', shutdown);
@ -292,7 +301,7 @@ const convertAudio = (streamInfo, res) => {
} }
} }
const convertGif = (streamInfo, res) => { const convertGif = async (streamInfo, res) => {
let process; let process;
const shutdown = () => (killProcess(process), closeResponse(res)); const shutdown = () => (killProcess(process), closeResponse(res));
@ -321,6 +330,7 @@ const convertGif = (streamInfo, res) => {
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, 60));
pipe(muxOutput, res, shutdown); pipe(muxOutput, res, shutdown);

View File

@ -0,0 +1,38 @@
import { Innertube } from 'youtubei.js';
import { Red } from '../misc/console-text.js'
const bail = (...msg) => {
console.error(...msg);
throw new Error(msg);
};
const tube = await Innertube.create();
tube.session.once(
'auth-pending',
({ verification_url, user_code }) => {
console.log(`${Red('[!]')} The token generated by this script is sensitive and you should not share it with anyone!`);
console.log(` By using this token, you are risking your Google account getting terminated.`);
console.log(` You should ${Red('NOT')} use your personal account!`);
console.log();
console.log(`Open ${verification_url} in a browser and enter ${user_code} when asked for the code.`);
}
);
tube.session.once('auth-error', (err) => bail('An error occurred:', err));
tube.session.once('auth', ({ credentials }) => {
if (!credentials.access_token) {
bail('something went wrong');
}
console.log(
'add this cookie to the youtube_oauth array in your cookies file:',
JSON.stringify(
Object.entries(credentials)
.map(([k, v]) => `${k}=${v instanceof Date ? v.toISOString() : v}`)
.join('; ')
)
);
});
await tube.session.signIn();

View File

@ -1,8 +1,7 @@
[ [
{ {
"name": "video (might have expired)", "name": "long link video",
"url": "https://www.xiaohongshu.com/explore/67cc17a3000000000e00726a?xsec_token=CBSFRtbF57so920elY1kbIX4fE1nhrwlpGZs9m6pIFpwo=", "url": "https://www.xiaohongshu.com/discovery/item/6789065900000000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share",
"canFail": true,
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
@ -10,9 +9,8 @@
} }
}, },
{ {
"name": "picker with multiple live photos (might have expired)", "name": "picker with multiple live photos",
"url": "https://www.xiaohongshu.com/explore/67c691b4000000000d0159cc?xsec_token=CB8p1eyB5DiFkwlUpy1BTeVsI9oOve6ppNjuDzo8V8p5w=", "url": "https://www.xiaohongshu.com/explore/67847fa1000000000203e6ed?xsec_token=CBzyP7Y44PPpsM20lgxqrIIJMHqOLemusDsRcmsX0cTpk",
"canFail": true,
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
@ -20,9 +18,8 @@
} }
}, },
{ {
"name": "one photo (might have expired)", "name": "one photo",
"url": "https://www.xiaohongshu.com/explore/676e132d000000000b016f68?xsec_token=ABRv6LKzizOFeSaf2HnnBkdBqniB5Ak1fI8tMAHzO31jA", "url": "https://www.xiaohongshu.com/explore/676e132d000000000b016f68?xsec_token=ABRv6LKzizOFeSaf2HnnBkdBqniB5Ak1fI8tMAHzO31jA",
"canFail": true,
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
@ -30,7 +27,7 @@
} }
}, },
{ {
"name": "short link (might have expired)", "name": "short link, might expire eventually",
"url": "https://xhslink.com/a/czn4z6c1tic4", "url": "https://xhslink.com/a/czn4z6c1tic4",
"canFail": true, "canFail": true,
"params": {}, "params": {},

View File

@ -189,7 +189,6 @@
{ {
"name": "hls video (h264, 1440p)", "name": "hls video (h264, 1440p)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI", "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"canFail": true,
"params": { "params": {
"youtubeVideoCodec": "h264", "youtubeVideoCodec": "h264",
"videoQuality": "1440", "videoQuality": "1440",
@ -203,7 +202,6 @@
{ {
"name": "hls video (vp9, 360p)", "name": "hls video (vp9, 360p)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI", "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"canFail": true,
"params": { "params": {
"youtubeVideoCodec": "vp9", "youtubeVideoCodec": "vp9",
"videoQuality": "360", "videoQuality": "360",
@ -217,7 +215,6 @@
{ {
"name": "hls video (audio mode)", "name": "hls video (audio mode)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI", "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"canFail": true,
"params": { "params": {
"downloadMode": "audio", "downloadMode": "audio",
"youtubeHLS": true "youtubeHLS": true
@ -230,7 +227,6 @@
{ {
"name": "hls video (audio mode, best format)", "name": "hls video (audio mode, best format)",
"url": "https://www.youtube.com/watch?v=vPwaXytZcgI", "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
"canFail": true,
"params": { "params": {
"downloadMode": "audio", "downloadMode": "audio",
"youtubeHLS": true, "youtubeHLS": true,

View File

@ -68,8 +68,8 @@ Content-Type: application/json
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. | | `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. | | `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. | | `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
| `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. | | `allowH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif | | `convertGif` | `boolean` | `true / false` | `true` | changes whether mute looping videos are converted to .gif |
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. | | `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
### response ### response

View File

@ -0,0 +1,33 @@
# how to configure a cobalt instance for youtube
if you get various errors when attempting to download videos that are:
publicly available, not region locked, and not age-restricted;
then your instance's ip address may have bad reputation.
in this case you have to use disposable google accounts.
there's no other known workaround as of time of writing this document.
> [!CAUTION]
> **NEVER** use your personal google account for downloading videos via any means.
> you can use any google accounts that you're willing to sacrifice,
> but be prepared to have them **permanently suspended**.
>
> we recommend that you use accounts that don't link back to your personal google account or identity, just in case.
>
> use incognito mode when signing in.
> we also recommend using vpn/proxy services (such as [mullvad](https://mullvad.net/)).
1. if you haven't done it already, clone the cobalt repo, go to the cloned directory, and run `pnpm install`
2. run `pnpm -C api token:youtube`
3. follow instructions, use incognito mode in your browser when signing in.
i cannot stress this enough, but again, **DO NOT USE YOUR PERSONAL GOOGLE ACCOUNT**.
4. once you have the oauth token, add it to `youtube_oauth` in your cookies file.
you can see an [example here](/docs/examples/cookies.example.json).
you can have several account tokens in this file, if you like.
5. all done! enjoy freedom.
### liability
you're responsible for any damage done to any of your google accounts or any other damages. you do this by yourself and at your own risk.

View File

@ -10,5 +10,8 @@
], ],
"twitter": [ "twitter": [
"auth_token=<replace_this>; ct0=<replace_this>" "auth_token=<replace_this>; ct0=<replace_this>"
],
"youtube_oauth": [
"<output from running `pnpm run token:youtube` in `api` folder goes here>"
] ]
} }

View File

@ -41,14 +41,3 @@ services:
command: --cleanup --scope cobalt --interval 900 --include-restarting command: --cleanup --scope cobalt --interval 900 --include-restarting
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# if needed, use this image for automatically generating poToken & visitor_data
# yt-session-generator:
# image: ghcr.io/imputnet/yt-session-generator:webserver
# init: true
# restart: unless-stopped
# container_name: yt-session-generator
# ports:
# - 127.0.0.1:1280:8080

View File

@ -81,7 +81,6 @@ sudo service nscd start
| `API_INSTANCE_COUNT` | | `2` | supported only on Linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. | | `API_INSTANCE_COUNT` | | `2` | supported only on Linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. |
| `DISABLED_SERVICES` | | `bilibili,youtube` | comma-separated list which disables certain services from being used. | | `DISABLED_SERVICES` | | `bilibili,youtube` | comma-separated list which disables certain services from being used. |
| `CUSTOM_INNERTUBE_CLIENT` | | `IOS` | innertube client that will be used instead of the default one. | | `CUSTOM_INNERTUBE_CLIENT` | | `IOS` | innertube client that will be used instead of the default one. |
| `YOUTUBE_SESSION_SERVER` | | `http://localhost:8080/` | url to an instance of [invidious' youtube-trusted-session-generator](https://github.com/iv-org/youtube-trusted-session-generator) or its fork/counterpart. used for automatically pulling poToken & visitor_data for youtube. can be local or remote. |
\* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)). \* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)).

31
pnpm-lock.yaml generated
View File

@ -43,6 +43,9 @@ importers:
ipaddr.js: ipaddr.js:
specifier: 2.2.0 specifier: 2.2.0
version: 2.2.0 version: 2.2.0
mime:
specifier: ^4.0.4
version: 4.0.4
nanoid: nanoid:
specifier: ^5.0.9 specifier: ^5.0.9
version: 5.0.9 version: 5.0.9
@ -56,8 +59,8 @@ importers:
specifier: 1.0.3 specifier: 1.0.3
version: 1.0.3 version: 1.0.3
youtubei.js: youtubei.js:
specifier: ^13.2.0 specifier: ^13.1.0
version: 13.2.0 version: 13.1.0
zod: zod:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.23.8 version: 3.23.8
@ -101,8 +104,8 @@ importers:
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
'@imput/libav.js-remux-cli': '@imput/libav.js-remux-cli':
specifier: ^5.5.6 specifier: ^6.5.7
version: 5.5.6 version: 6.5.7
'@imput/version-info': '@imput/version-info':
specifier: workspace:^ specifier: workspace:^
version: link:../packages/version-info version: link:../packages/version-info
@ -188,8 +191,8 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
'@bufbuild/protobuf@2.2.5': '@bufbuild/protobuf@2.1.0':
resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==} resolution: {integrity: sha512-+2Mx67Y3skJ4NCD/qNSdBJNWtu6x6Qr53jeNg+QcwiL6mt0wK+3jwHH2x1p7xaYH6Ve2JKOVn0OxU35WsmqI9A==}
'@datastructures-js/heap@4.3.3': '@datastructures-js/heap@4.3.3':
resolution: {integrity: sha512-UcUu/DLh/aM4W3C8zZfwxxm6/6FIZUlm3mcAXuNOCa6Aj4iizNvNXQyb8DjZQH2jKSQbMRyNlngP6TPimuGjpQ==} resolution: {integrity: sha512-UcUu/DLh/aM4W3C8zZfwxxm6/6FIZUlm3mcAXuNOCa6Aj4iizNvNXQyb8DjZQH2jKSQbMRyNlngP6TPimuGjpQ==}
@ -554,8 +557,8 @@ packages:
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
'@imput/libav.js-remux-cli@5.5.6': '@imput/libav.js-remux-cli@6.5.7':
resolution: {integrity: sha512-XdAab90EZKf6ULtD/x9Y2bnlmNJodXSO6w8aWrn97+N2IRuOS8zv3tAFPRC69SWKa8Utjeu5YTYuTolnX3QprQ==} resolution: {integrity: sha512-41ld+R5aEwllKdbiszOoCf+K614/8bnuheTZnqjgZESwDtX07xu9O8GO0m/Cpsm7O2CyqPZa1qzDx6BZVO15DQ==}
'@imput/psl@2.0.4': '@imput/psl@2.0.4':
resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==} resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==}
@ -2286,8 +2289,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
youtubei.js@13.2.0: youtubei.js@13.1.0:
resolution: {integrity: sha512-esbSvWS12Dz/cVlHhnL/PSE84a/mVpQdzwPDIkRQu/NHJVxv0isBUcm3hJnYB1jg1LYvomV0YeOrYv5qWwJREA==} resolution: {integrity: sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==}
zod@3.23.8: zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
@ -2299,7 +2302,7 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.5 '@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
'@bufbuild/protobuf@2.2.5': {} '@bufbuild/protobuf@2.1.0': {}
'@datastructures-js/heap@4.3.3': {} '@datastructures-js/heap@4.3.3': {}
@ -2519,7 +2522,7 @@ snapshots:
'@humanwhocodes/retry@0.4.1': {} '@humanwhocodes/retry@0.4.1': {}
'@imput/libav.js-remux-cli@5.5.6': {} '@imput/libav.js-remux-cli@6.5.7': {}
'@imput/psl@2.0.4': '@imput/psl@2.0.4':
dependencies: dependencies:
@ -4242,9 +4245,9 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
youtubei.js@13.2.0: youtubei.js@13.1.0:
dependencies: dependencies:
'@bufbuild/protobuf': 2.2.5 '@bufbuild/protobuf': 2.1.0
jintr: 3.2.1 jintr: 3.2.1
tslib: 2.6.3 tslib: 2.6.3
undici: 5.28.4 undici: 5.28.4

View File

@ -16,5 +16,11 @@
"save": "save", "save": "save",
"export": "export", "export": "export",
"yes": "yes", "yes": "yes",
"no": "no" "no": "no",
"clear": "clear",
"show_input": "show input",
"hide_input": "hide input",
"restore_input": "restore input",
"clear_input": "clear input",
"clear_cache": "clear cache"
} }

View File

@ -1,6 +1,6 @@
{ {
"reset.title": "reset all data?", "reset_settings.title": "reset all settings?",
"reset.body": "are you sure you want to reset all data? this action is immediate and irreversible.", "reset_settings.body": "are you sure you want to reset all settings? this action is immediate and irreversible.",
"picker.title": "select what to save", "picker.title": "select what to save",
"picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.", "picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.",
@ -21,5 +21,8 @@
"safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.", "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.",
"processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?", "processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?",
"processing.title.ongoing": "processing will be cancelled" "processing.title.ongoing": "processing will be cancelled",
"clear_cache.title": "clear all cache?",
"clear_cache.body": "all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible."
} }

View File

@ -51,7 +51,7 @@
"api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!", "api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!",
"api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!", "api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!",
"api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!", "api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!",
"api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!", "api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try again or try a different link!",
"api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!", "api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!",
"api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!", "api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!",
@ -59,15 +59,14 @@
"api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!", "api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!",
"api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!", "api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!",
"api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!", "api.content.post.age": "this post is age-restricted, so i can't access it anonymously. try again or try a different link!",
"api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!", "api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!",
"api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!", "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!",
"api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!", "api.youtube.login": "couldn't get this video because youtube told the processing instance that it's a bot and restricted its access. try again in a few seconds, but if it still doesn't work, please report this issue!",
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
"api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!", "api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
"api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!", "api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
"api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!", "api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
"api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!", "api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!"
"api.youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
} }

17
web/i18n/en/queue.json Normal file
View File

@ -0,0 +1,17 @@
{
"title": "processing queue",
"stub": "nothing here yet, just the two of us.\ntry {{ value }} something!",
"stub.download": "downloading",
"stub.remux": "remuxing",
"state.waiting": "queued",
"state.retrying": "retrying",
"state.starting": "starting",
"state.starting.fetch": "starting downloading",
"state.starting.remux": "starting muxing",
"state.running.remux": "muxing",
"state.running.fetch": "downloading",
"estimated_storage_usage": "estimated storage usage:"
}

View File

@ -1,5 +1,7 @@
{ {
"title": "drag or select a file", "title": "drag or select a file",
"title.multiple": "drag or select files",
"title.drop": "drop the file here!", "title.drop": "drop the file here!",
"title.drop.multiple": "drop the files here!",
"accept": "supported formats: {{ formats }}." "accept": "supported formats: {{ formats }}."
} }

View File

@ -7,6 +7,8 @@
"page.advanced": "advanced", "page.advanced": "advanced",
"page.debug": "info for nerds", "page.debug": "info for nerds",
"page.instances": "instances", "page.instances": "instances",
"page.local": "local processing",
"page.accessibility": "accessibility",
"section.general": "general", "section.general": "general",
"section.save": "save", "section.save": "save",
@ -72,7 +74,7 @@
"metadata.filename.nerdy": "nerdy", "metadata.filename.nerdy": "nerdy",
"metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.", "metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.",
"metadata.filename.preview.video": "Video Title", "metadata.filename.preview.video": "Video Title - Video Author",
"metadata.filename.preview.audio": "Audio Title - Audio Author", "metadata.filename.preview.audio": "Audio Title - Audio Author",
"metadata.file": "file metadata", "metadata.file": "file metadata",
@ -86,11 +88,18 @@
"saving.copy": "copy", "saving.copy": "copy",
"saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.", "saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.",
"accessibility": "accessibility", "accessibility.visual": "visual",
"accessibility.haptics": "haptics",
"accessibility.behavior": "behavior",
"accessibility.transparency.title": "reduce visual transparency", "accessibility.transparency.title": "reduce visual transparency",
"accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects. may also improve ui performance on low performance devices.", "accessibility.transparency.description": "transparency of surfaces will be reduced and all blur effects will be disabled. may also improve ui performance on less powerful devices.",
"accessibility.motion.title": "reduce motion", "accessibility.motion.title": "reduce motion",
"accessibility.motion.description": "disables animations and transitions whenever possible.", "accessibility.motion.description": "animations and transitions will be disabled whenever possible.",
"accessibility.haptics.title": "disable haptics",
"accessibility.haptics.description": "all haptic effects will be disabled.",
"accessibility.auto_queue.title": "don't open the queue automatically",
"accessibility.auto_queue.description": "the processing queue will not be opened automatically whenever a new item is added to it. progress will still be displayed and you will still be able to open it manually.",
"language": "language", "language": "language",
"language.auto.title": "automatic selection", "language.auto.title": "automatic selection",
@ -111,8 +120,6 @@
"advanced.debug.title": "enable features for nerds", "advanced.debug.title": "enable features for nerds",
"advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.", "advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.",
"advanced.data": "data management",
"processing.community": "community instances", "processing.community": "community instances",
"processing.enable_custom.title": "use a custom processing server", "processing.enable_custom.title": "use a custom processing server",
"processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.", "processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.",
@ -122,5 +129,16 @@
"processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!", "processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!",
"processing.custom_instance.input.alt_text": "custom instance domain", "processing.custom_instance.input.alt_text": "custom instance domain",
"processing.access_key.input.alt_text": "u-u-i-d access key" "processing.access_key.input.alt_text": "u-u-i-d access key",
"advanced.settings_data": "settings data",
"advanced.local_storage": "local storage",
"local.saving": "media processing",
"local.saving.title": "mux and convert media on device",
"local.saving.description": "when downloading media, cobalt will do needed processing on-device instead of using cloud compute. files will download faster and more reliably.\n\nexclusive local features are not affected by this toggle, they always run locally.",
"local.webcodecs": "webcodecs",
"local.webcodecs.title": "use webcodecs for on-device processing",
"local.webcodecs.description": "when decoding or encoding files, cobalt will try to use webcodecs. this feature allows for GPU-accelerated processing of media files, meaning that all decoding & encoding will be way faster.\n\navailability and stability of this feature depends on your device's and browser's capabilities. stuff might break or not work properly."
} }

View File

@ -28,7 +28,7 @@
"@fontsource-variable/noto-sans-mono": "^5.0.20", "@fontsource-variable/noto-sans-mono": "^5.0.20",
"@fontsource/ibm-plex-mono": "^5.0.13", "@fontsource/ibm-plex-mono": "^5.0.13",
"@fontsource/redaction-10": "^5.0.2", "@fontsource/redaction-10": "^5.0.2",
"@imput/libav.js-remux-cli": "^5.5.6", "@imput/libav.js-remux-cli": "^6.5.7",
"@imput/version-info": "workspace:^", "@imput/version-info": "workspace:^",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.1", "@sveltejs/kit": "^2.9.1",

View File

@ -18,7 +18,7 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png"> <link rel="icon" href="%sveltekit.assets%/favicon.png">
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/icons/apple-touch-icon.png">
<link type="application/activity+json" href="" /> <link type="application/activity+json" href="">
<link crossorigin="use-credentials" rel="manifest" href="%sveltekit.assets%/manifest.json"> <link crossorigin="use-credentials" rel="manifest" href="%sveltekit.assets%/manifest.json">

View File

@ -38,7 +38,7 @@
</script> </script>
<button <button
class="support-card" class="button support-card"
role="link" role="link"
on:click={() => { on:click={() => {
openURL(externalLink); openURL(externalLink);
@ -68,7 +68,6 @@
.support-card { .support-card {
padding: var(--padding); padding: var(--padding);
gap: 4px; gap: 4px;
height: fit-content;
text-align: start; text-align: start;
flex-direction: column; flex-direction: column;

View File

@ -6,6 +6,8 @@
Value extends CobaltSettings[Context][Id] Value extends CobaltSettings[Context][Id]
" "
> >
import { hapticSwitch } from "$lib/haptics";
import settings, { updateSetting } from "$lib/state/settings"; import settings, { updateSetting } from "$lib/state/settings";
import type { CobaltSettings } from "$lib/types/settings"; import type { CobaltSettings } from "$lib/types/settings";
@ -22,12 +24,14 @@
class="button" class="button"
class:active={isActive} class:active={isActive}
aria-pressed={isActive} aria-pressed={isActive}
on:click={() => on:click={() => {
hapticSwitch();
updateSetting({ updateSetting({
[settingContext]: { [settingContext]: {
[settingId]: settingValue, [settingId]: settingValue,
}, },
})} });
}}
> >
<slot></slot> <slot></slot>
</button> </button>

View File

@ -5,6 +5,7 @@
Id extends keyof CobaltSettings[Context] Id extends keyof CobaltSettings[Context]
" "
> >
import { hapticSwitch } from "$lib/haptics";
import settings, { updateSetting } from "$lib/state/settings"; import settings, { updateSetting } from "$lib/state/settings";
import type { CobaltSettings } from "$lib/types/settings"; import type { CobaltSettings } from "$lib/types/settings";
@ -31,17 +32,18 @@
aria-hidden={disabled} aria-hidden={disabled}
> >
<button <button
class="toggle-container" class="button toggle-container"
role="switch" role="switch"
aria-checked={isEnabled} aria-checked={isEnabled}
disabled={disabled} {disabled}
on:click={() => on:click={() => {
hapticSwitch();
updateSetting({ updateSetting({
[settingContext]: { [settingContext]: {
[settingId]: !isEnabled, [settingId]: !isEnabled,
} },
}) });
} }}
> >
<h4 class="toggle-title">{title}</h4> <h4 class="toggle-title">{title}</h4>
<Toggle enabled={isEnabled} /> <Toggle enabled={isEnabled} />
@ -81,5 +83,12 @@
padding: calc(var(--switcher-padding) * 2) 16px; padding: calc(var(--switcher-padding) * 2) 16px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
overflow: scroll; overflow: scroll;
transition: box-shadow 0.1s;
}
.toggle-container:active {
box-shadow:
var(--button-box-shadow),
0 0 0 1.5px var(--button-stroke) inset;
} }
</style> </style>

View File

@ -75,7 +75,7 @@
.switcher.big :global(.button) { .switcher.big :global(.button) {
width: 100%; width: 100%;
/* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */ /* [base button height] - ([switcher padding] * [padding factor to accommodate for]) */
height: calc(40px - var(--switcher-padding) * 1.5); height: calc(40px - var(--switcher-padding) * 2);
border-radius: calc(var(--border-radius) - var(--switcher-padding));; border-radius: calc(var(--border-radius) - var(--switcher-padding));;
} }
@ -87,12 +87,16 @@
background-color: transparent; background-color: transparent;
} }
.switcher.big :global(.button:active:not(.active)) {
box-shadow: var(--button-box-shadow);
}
.switcher:not(.big) :global(.button:not(:first-child, :last-child)) { .switcher:not(.big) :global(.button:not(:first-child, :last-child)) {
border-radius: 0; border-radius: 0;
} }
/* hack to get rid of double border in a list of switches */ /* hack to get rid of double border in a list of switches */
.switcher:not(.big) :global(:not(.button:first-child)) { .switcher:not(.big) :global(:not(.button:first-child)) {
margin-left: -1.5px; margin-left: -1px;
} }
</style> </style>

View File

@ -16,9 +16,14 @@
if (dialogParent) { if (dialogParent) {
closing = true; closing = true;
open = false; open = false;
// wait 150ms for the closing animation to finish
setTimeout(() => { setTimeout(() => {
dialogParent.close(); // check if dialog parent is still present
killDialog(); if (dialogParent) {
dialogParent.close();
killDialog();
}
}, 150); }, 150);
} }
}; };

View File

@ -136,7 +136,11 @@
:global(dialog .dialog-body) { :global(dialog .dialog-body) {
margin-bottom: calc( margin-bottom: calc(
var(--padding) / 2 + env(safe-area-inset-bottom) var(--padding) + calc(
env(safe-area-inset-bottom) - 15px * sign(
env(safe-area-inset-bottom)
)
)
) !important; ) !important;
box-shadow: 0 0 0 2px var(--popup-stroke) inset; box-shadow: 0 0 0 2px var(--popup-stroke) inset;
} }

View File

@ -65,6 +65,9 @@
<style> <style>
.picker-dialog { .picker-dialog {
--picker-item-size: 120px; --picker-item-size: 120px;
--picker-item-gap: 4px;
--picker-item-area: calc(var(--picker-item-size) + var(--picker-item-gap));
gap: var(--padding); gap: var(--padding);
max-height: calc( max-height: calc(
90% - env(safe-area-inset-bottom) - env(safe-area-inset-top) 90% - env(safe-area-inset-bottom) - env(safe-area-inset-top)
@ -77,7 +80,7 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 3px; gap: 3px;
max-width: calc(var(--picker-item-size) * 4); max-width: calc(var(--picker-item-area) * 4);
} }
.popup-title-container { .popup-title-container {
@ -112,6 +115,7 @@
display: grid; display: grid;
justify-items: center; justify-items: center;
grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr;
gap: var(--picker-item-gap);
} }
.three-columns .picker-body { .three-columns .picker-body {
@ -119,7 +123,7 @@
} }
.three-columns .popup-header { .three-columns .popup-header {
max-width: calc(var(--picker-item-size) * 3); max-width: calc(var(--picker-item-area) * 3);
} }
:global(.picker-item) { :global(.picker-item) {
@ -133,48 +137,78 @@
} }
.popup-header { .popup-header {
max-width: calc(var(--picker-item-size) * 3); max-width: calc(var(--picker-item-area) * 3);
} }
} }
@media screen and (max-width: 400px) { @media screen and (max-width: 410px) {
.picker-dialog {
--picker-item-size: 118px;
}
}
@media screen and (max-width: 405px) {
.picker-dialog {
--picker-item-size: 116px;
}
}
@media screen and (max-width: 398px) {
.picker-dialog { .picker-dialog {
--picker-item-size: 115px; --picker-item-size: 115px;
} }
} }
@media screen and (max-width: 380px) { @media screen and (max-width: 388px) {
.picker-dialog { .picker-dialog {
--picker-item-size: 110px; --picker-item-size: 110px;
} }
} }
@media screen and (max-width: 365px) { @media screen and (max-width: 378px) {
.picker-dialog { .picker-dialog {
--picker-item-size: 105px; --picker-item-size: 105px;
} }
} }
@media screen and (max-width: 350px) { @media screen and (max-width: 365px) {
.picker-dialog { .picker-dialog {
--picker-item-size: 100px; --picker-item-size: 100px;
} }
} }
@media screen and (max-width: 335px) { @media screen and (max-width: 352px) {
.picker-dialog {
--picker-item-size: 95px;
}
}
@media screen and (max-width: 334px) {
.picker-dialog {
--picker-item-size: 130px;
}
.picker-body, .picker-body,
.three-columns .picker-body { .three-columns .picker-body {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
}
.popup-header { @media screen and (max-width: 300px) {
max-width: calc(var(--picker-item-size) * 3); .picker-dialog {
--picker-item-size: 120px;
}
}
@media screen and (max-width: 280px) {
.picker-dialog {
--picker-item-size: 110px;
} }
} }
@media screen and (max-width: 255px) { @media screen and (max-width: 255px) {
.picker-dialog { .picker-dialog {
--picker-item-size: 120px; --picker-item-size: 140px;
} }
.picker-body, .picker-body,

View File

@ -62,11 +62,20 @@
.picker-item { .picker-item {
position: relative; position: relative;
background: none; background: none;
padding: 2px; padding: 0;
box-shadow: none; box-shadow: none;
border-radius: calc(var(--border-radius) / 2 + 2px); border-radius: calc(var(--border-radius) / 2 + 2px);
} }
.picker-item:focus-visible::after {
content: "";
width: 100%;
height: 100%;
position: absolute;
box-shadow: var(--focus-ring);
border-radius: inherit;
}
:global(.picker-image) { :global(.picker-image) {
display: block; display: block;
width: 100%; width: 100%;
@ -76,7 +85,7 @@
pointer-events: all; pointer-events: all;
object-fit: cover; object-fit: cover;
border-radius: calc(var(--border-radius) / 2); border-radius: inherit;
} }
.picker-image.loading { .picker-image.loading {

View File

@ -2,6 +2,7 @@
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { device } from "$lib/device"; import { device } from "$lib/device";
import { hapticConfirm } from "$lib/haptics";
import { import {
copyURL, copyURL,
openURL, openURL,
@ -101,8 +102,11 @@
fill fill
elevated elevated
click={async () => { click={async () => {
copyURL(url); if (!copied) {
copied = true; copyURL(url);
hapticConfirm();
copied = true;
}
}} }}
ariaLabel={copied ? $t("button.copied") : ""} ariaLabel={copied ? $t("button.copied") : ""}
> >

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { hapticError } from "$lib/haptics";
import type { Optional } from "$lib/types/generic"; import type { Optional } from "$lib/types/generic";
import type { MeowbaltEmotions } from "$lib/types/meowbalt"; import type { MeowbaltEmotions } from "$lib/types/meowbalt";
import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog"; import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog";
@ -21,6 +22,11 @@
export let leftAligned = false; export let leftAligned = false;
let close: () => void; let close: () => void;
// error meowbalt art is not used in dialogs unless it's an error
if (meowbalt === "error") {
hapticError();
}
</script> </script>
<DialogContainer {id} {dismissable} bind:close> <DialogContainer {id} {dismissable} bind:close>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { hapticConfirm } from "$lib/haptics";
import { copyURL, openURL } from "$lib/download"; import { copyURL, openURL } from "$lib/download";
import CopyIcon from "$components/misc/CopyIcon.svelte"; import CopyIcon from "$components/misc/CopyIcon.svelte";
@ -21,14 +22,17 @@
<div class="wallet-holder"> <div class="wallet-holder">
<button <button
class="wallet" class="button wallet"
aria-label={$t(`donate.alt.${type}`, { aria-label={$t(`donate.alt.${type}`, {
value: name, value: name,
})} })}
on:click={() => { on:click={() => {
if (type === "copy") { if (type === "copy") {
copied = true; if (!copied) {
copyURL(address); copyURL(address);
hapticConfirm();
copied = true;
}
} else { } else {
openURL(address); openURL(address);
} }
@ -88,7 +92,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-right: 1.5px var(--button-stroke) solid; border-right: 1px var(--button-stroke) solid;
margin-left: 3px; margin-left: 3px;
} }

View File

@ -47,18 +47,19 @@
box-shadow: none; box-shadow: none;
} }
:global(.donate-card button:active) {
background: rgba(255, 255, 255, 0.1);
}
@media (hover: hover) { @media (hover: hover) {
:global(.donate-card button:hover) { :global(.donate-card button:hover:not(.selected):not(.scroll-button)) {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
} }
:global(.donate-card button:active:not(.selected):not(.scroll-button)) {
background: rgba(255, 255, 255, 0.125);
}
:global(.donate-card button.selected) { :global(.donate-card button.selected) {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
cursor: default;
} }
:global(.donate-card button.selected:not(:focus-visible)) { :global(.donate-card button.selected:not(:focus-visible)) {

View File

@ -75,7 +75,7 @@
return window.open(donationMethods[processor](amount), "_blank"); return window.open(donationMethods[processor](amount), "_blank");
}; };
const scrollBehavior = $settings.appearance.reduceMotion const scrollBehavior = $settings.accessibility.reduceMotion
? "instant" ? "instant"
: "smooth"; : "smooth";
@ -85,7 +85,7 @@
const scroll = (direction: "left" | "right") => { const scroll = (direction: "left" | "right") => {
const currentPos = donateList.scrollLeft; const currentPos = donateList.scrollLeft;
const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width; const maxPos = donateList.scrollWidth - donateList.getBoundingClientRect().width;
const newPos = direction === "left" ? currentPos - 150 : currentPos + 150; const newPos = direction === "left" ? currentPos - 250 : currentPos + 250;
donateList.scroll({ donateList.scroll({
left: newPos, left: newPos,
@ -285,10 +285,17 @@
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;
color: var(--white); color: var(--white);
background-color: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
}
@media (hover: hover) {
#input-container:hover {
background: rgba(255, 255, 255, 0.1);
}
} }
#input-dollar-sign { #input-dollar-sign {
@ -336,7 +343,6 @@
#donation-custom-submit { #donation-custom-submit {
color: var(--white); color: var(--white);
background-color: rgba(255, 255, 255, 0.1);
aspect-ratio: 1/1; aspect-ratio: 1/1;
padding: 0px 10px; padding: 0px 10px;
} }

View File

@ -3,6 +3,7 @@
import { device } from "$lib/device"; import { device } from "$lib/device";
import locale from "$lib/i18n/locale"; import locale from "$lib/i18n/locale";
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { hapticConfirm } from "$lib/haptics";
import { openURL, copyURL, shareURL } from "$lib/download"; import { openURL, copyURL, shareURL } from "$lib/download";
@ -51,8 +52,11 @@
id="action-button-copy" id="action-button-copy"
class="action-button" class="action-button"
on:click={async () => { on:click={async () => {
copyURL(cobaltUrl); if (!copied) {
copied = true; copyURL(cobaltUrl);
hapticConfirm();
copied = true;
}
}} }}
aria-label={copied ? $t("button.copied") : ""} aria-label={copied ? $t("button.copied") : ""}
> >
@ -176,7 +180,7 @@
.action-button { .action-button {
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 0 10px; padding: 0 6px;
font-size: 13px; font-size: 13px;
gap: 2px; gap: 2px;
} }

View File

@ -3,15 +3,15 @@
export let classes = ""; export let classes = "";
export let draggedOver = false; export let draggedOver = false;
export let file: File | undefined; export let files: FileList | undefined;
const dropHandler = async (ev: DragEvent) => { const dropHandler = async (ev: DragEvent) => {
draggedOver = false; draggedOver = false;
ev.preventDefault(); ev.preventDefault();
if (ev?.dataTransfer?.files.length === 1) { if (ev?.dataTransfer?.files && ev?.dataTransfer?.files.length > 0) {
file = ev.dataTransfer.files[0]; files = ev.dataTransfer.files;
return file; return files;
} }
}; };
@ -25,6 +25,7 @@
{id} {id}
class={classes} class={classes}
role="region" role="region"
aria-hidden="true"
on:drop={(ev) => dropHandler(ev)} on:drop={(ev) => dropHandler(ev)}
on:dragover={(ev) => dragOverHandler(ev)} on:dragover={(ev) => dragOverHandler(ev)}
on:dragend={() => { on:dragend={() => {

View File

@ -5,22 +5,33 @@
import IconFileImport from "@tabler/icons-svelte/IconFileImport.svelte"; import IconFileImport from "@tabler/icons-svelte/IconFileImport.svelte";
import IconUpload from "@tabler/icons-svelte/IconUpload.svelte"; import IconUpload from "@tabler/icons-svelte/IconUpload.svelte";
export let file: File | undefined; export let files: FileList | undefined;
export let draggedOver = false; export let draggedOver = false;
export let acceptTypes: string[]; export let acceptTypes: string[];
export let acceptExtensions: string[]; export let acceptExtensions: string[];
export let maxFileNumber: number = 100;
let selectorStringMultiple = maxFileNumber > 1 ? ".multiple" : "";
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
const openFile = async () => { const openFile = async () => {
fileInput = document.createElement("input"); fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.type = "file";
fileInput.accept = acceptTypes.join(","); fileInput.accept = acceptTypes.join(",");
if (maxFileNumber > 1) {
fileInput.multiple = true;
}
fileInput.click(); fileInput.click();
fileInput.onchange = async () => { fileInput.onchange = async () => {
if (fileInput.files?.length === 1) { let userFiles = fileInput?.files;
file = fileInput.files[0]; if (userFiles && userFiles.length >= 1) {
return file; if (userFiles.length > maxFileNumber) {
return alert("too many files, limit is " + maxFileNumber);
}
return files = userFiles;
} }
}; };
}; };
@ -29,7 +40,7 @@
<div class="open-file-container" class:dragged-over={draggedOver}> <div class="open-file-container" class:dragged-over={draggedOver}>
<Meowbalt emotion="question" /> <Meowbalt emotion="question" />
<button class="open-file-button" on:click={openFile}> <button class="button open-file-button" on:click={openFile}>
<div class="dashed-stroke"> <div class="dashed-stroke">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"> <svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="none" rx="24" ry="24" /> <rect width="100%" height="100%" fill="none" rx="24" ry="24" />
@ -47,9 +58,9 @@
<div class="open-file-text"> <div class="open-file-text">
<div class="open-title"> <div class="open-title">
{#if draggedOver} {#if draggedOver}
{$t("receiver.title.drop")} {$t("receiver.title.drop" + selectorStringMultiple)}
{:else} {:else}
{$t("receiver.title")} {$t("receiver.title" + selectorStringMultiple)}
{/if} {/if}
</div> </div>
<div class="subtext accept-list"> <div class="subtext accept-list">

View File

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import type { MeowbaltEmotions } from "$lib/types/meowbalt"; import type { MeowbaltEmotions } from "$lib/types/meowbalt";
export let emotion: MeowbaltEmotions; export let emotion: MeowbaltEmotions;

View File

@ -0,0 +1,72 @@
<script lang="ts">
export let id = "";
export let expanded = false;
export let expandStart: "left" | "center" | "right" = "center";
/*
a popover isn't pre-rendered by default, because the user might never open it.
but if they do, we render only once, and then keep it the dom :3
*/
$: renderPopover = false;
$: if (expanded && !renderPopover) renderPopover = true;
</script>
<div {id} class="popover {expandStart}" aria-hidden={!expanded} class:expanded>
{#if renderPopover}
<slot></slot>
{/if}
</div>
<style>
.popover {
display: flex;
flex-direction: column;
border-radius: 18px;
background: var(--button);
box-shadow:
var(--button-box-shadow),
0 0 10px 10px var(--popover-glow);
position: relative;
padding: var(--padding);
gap: 6px;
top: 6px;
z-index: 2;
opacity: 0;
transform: scale(0);
transform-origin: top center;
transition:
transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),
opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);
pointer-events: all;
}
.popover.left {
transform-origin: top left;
}
:global([dir="rtl"]) .popover.left {
transform-origin: top right;
}
.popover.center {
transform-origin: top center;
}
.popover.right {
transform-origin: top right;
}
:global([dir="rtl"]) .popover.right {
transform-origin: top left;
}
.popover.expanded {
opacity: 1;
transform: none;
}
</style>

View File

@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import { t } from "$lib/i18n/translations";
import { copyURL } from "$lib/download"; import { copyURL } from "$lib/download";
import { t } from "$lib/i18n/translations";
import { hapticConfirm } from "$lib/haptics";
import CopyIcon from "$components/misc/CopyIcon.svelte"; import CopyIcon from "$components/misc/CopyIcon.svelte";
export let title: string; export let title: string;
export let sectionId: string; export let sectionId: string;
export let beta = false; export let beta = false;
export let nolink = false;
export let copyData = ""; export let copyData = "";
const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`; const sectionURL = `${$page.url.origin}${$page.url.pathname}#${sectionId}`;
@ -32,18 +34,23 @@
</div> </div>
{/if} {/if}
<button {#if !nolink}
class="link-copy" <button
aria-label={copied class="link-copy"
? $t("button.copied") aria-label={copied
: $t(`button.copy${copyData ? "" : ".section"}`)} ? $t("button.copied")
on:click={() => { : $t(`button.copy${copyData ? "" : ".section"}`)}
copied = true; on:click={() => {
copyURL(copyData || sectionURL); if (!copied) {
}} copyURL(copyData || sectionURL);
> hapticConfirm();
<CopyIcon check={copied} regularIcon={!!copyData} /> copied = true;
</button> }
}}
>
<CopyIcon check={copied} regularIcon={!!copyData} />
</button>
{/if}
</div> </div>
<style> <style>
@ -90,7 +97,7 @@
color: var(--primary); color: var(--primary);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
line-height: 1.9; line-height: 1.86;
text-transform: uppercase; text-transform: uppercase;
} }

View File

@ -21,7 +21,7 @@
border-radius: 5px; border-radius: 5px;
border-radius: 100px; border-radius: 100px;
background: var(--toggle-bg); background: var(--toggle-bg);
transition: background 0.2s; transition: background 0.25s;
} }
.toggle:dir(rtl) { .toggle:dir(rtl) {
@ -34,7 +34,7 @@
background: var(--white); background: var(--white);
border-radius: 100px; border-radius: 100px;
transform: translateX(0%); transform: translateX(0%);
transition: transform 0.2s, width 0.2s; transition: transform 0.25s cubic-bezier(0.53, 0.05, 0.02, 1.2);
} }
.toggle.enabled { .toggle.enabled {
@ -44,8 +44,4 @@
.toggle.enabled .toggle-switcher { .toggle.enabled .toggle-switcher {
transform: translateX(var(--enabled-pos)); transform: translateX(var(--enabled-pos));
} }
:global(.toggle-container:active .toggle:not(.enabled) .toggle-switcher) {
width: calc(var(--base-size) * 1.3);
}
</style> </style>

View File

@ -1,11 +1,19 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import IconComet from "@tabler/icons-svelte/IconComet.svelte"; import IconComet from "@tabler/icons-svelte/IconComet.svelte";
let dismissed = false;
</script> </script>
<div id="update-notification" role="alert" aria-atomic="true"> <div id="update-notification" role="alert" aria-atomic="true">
<button class="update-button" on:click={() => window.location.reload()}> <button
class="button update-button"
class:visible={!dismissed}
on:click={() => {
dismissed = true;
window.location.reload()
}}
>
<div class="update-icon"> <div class="update-icon">
<IconComet /> <IconComet />
</div> </div>
@ -32,12 +40,19 @@
pointer-events: all; pointer-events: all;
gap: 8px; gap: 8px;
margin: var(--padding); margin: var(--padding);
margin-right: 71px;
margin-top: calc(env(safe-area-inset-top) + var(--padding)); margin-top: calc(env(safe-area-inset-top) + var(--padding));
box-shadow: box-shadow:
var(--button-box-shadow), var(--button-box-shadow),
0 0 10px 0px var(--button-elevated-hover); 0 0 10px 0px var(--button-elevated-hover);
border-radius: 14px; border-radius: 14px;
animation: slide-in-top 0.4s;
transform: translateY(-150px);
transition: transform 0.4s cubic-bezier(0.53, 0.05, 0.23, 1.15);
}
.update-button.visible {
transform: none;
} }
.update-icon { .update-icon {
@ -74,29 +89,15 @@
line-height: 1.2; line-height: 1.2;
} }
@keyframes slide-in-top {
from {
transform: translateY(-150px);
}
100% {
transform: none;
}
}
@media screen and (max-width: 535px) { @media screen and (max-width: 535px) {
#update-notification { #update-notification {
bottom: var(--sidebar-height-mobile); bottom: calc(var(--sidebar-height-mobile) + 5px);
justify-content: center; justify-content: center;
animation: slide-in-bottom 0.4s;
} }
@keyframes slide-in-bottom { .update-button {
from { transform: translateY(300px);
transform: translateY(300px); margin-right: var(--padding);
}
100% {
transform: none;
}
} }
} }
</style> </style>

View File

@ -0,0 +1,212 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { onNavigate } from "$app/navigation";
import { onMount, type SvelteComponent } from "svelte";
import { formatFileSize } from "$lib/util";
import { clearFileStorage, getStorageQuota } from "$lib/storage";
import { queueVisible } from "$lib/state/queue-visibility";
import { currentTasks } from "$lib/state/queen-bee/current-tasks";
import { clearQueue, queue as readableQueue } from "$lib/state/queen-bee/queue";
import SectionHeading from "$components/misc/SectionHeading.svelte";
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
import ProcessingStatus from "$components/queue/ProcessingStatus.svelte";
import ProcessingQueueItem from "$components/queue/ProcessingQueueItem.svelte";
import ProcessingQueueStub from "$components/queue/ProcessingQueueStub.svelte";
import IconX from "@tabler/icons-svelte/IconX.svelte";
let popover: SvelteComponent;
let quotaUsage = 0;
const updateQuota = async () => {
const storageInfo = await getStorageQuota();
quotaUsage = storageInfo?.usage || 0;
}
const popoverAction = () => {
$queueVisible = !$queueVisible;
};
const totalItemProgress = (completed: number, current: number, total: number) => {
return (completed * 100 + current) / total
}
$: queue = Object.entries($readableQueue);
$: totalProgress = queue.length ? queue.map(([, item]) => {
if (item.state === "done" || item.state === "error") {
return 100;
} else if (item.state === "running") {
return totalItemProgress(
item.completedWorkers?.length || 0,
$currentTasks[item.runningWorker]?.progress?.percentage || 0,
item.pipeline.length || 0
);
}
return 0;
}).reduce((a, b) => a + b) / (100 * queue.length) : 0;
$: indeterminate = queue.length > 0 && totalProgress === 0;
$: if ($queueVisible) {
updateQuota();
}
onNavigate(() => {
$queueVisible = false;
});
onMount(() => {
// clear old files from storage on first page load
clearFileStorage();
});
</script>
<div id="processing-queue" class:expanded={$queueVisible}>
<ProcessingStatus
progress={totalProgress * 100}
{indeterminate}
expandAction={popoverAction}
/>
<PopoverContainer
bind:this={popover}
id="processing-popover"
expanded={$queueVisible}
expandStart="right"
>
<div id="processing-header">
<div class="header-top">
<SectionHeading
title={$t("queue.title")}
sectionId="queue"
beta
nolink
/>
<div class="header-buttons">
{#if queue.length}
<button class="clear-button" on:click={() => {
clearQueue();
updateQuota();
}}>
<IconX />
{$t("button.clear")}
</button>
{/if}
</div>
</div>
{#if quotaUsage}
<div class="storage-info">
{$t("queue.estimated_storage_usage")} {formatFileSize(quotaUsage)}
</div>
{/if}
</div>
<div id="processing-list">
{#each queue as [id, item]}
<ProcessingQueueItem
{id}
info={item}
runningWorker={
item.state === "running" ? $currentTasks[item.runningWorker] : undefined
}
runningWorkerId={
item.state === "running" ? item.runningWorker : undefined
}
/>
{/each}
{#if queue.length === 0}
<ProcessingQueueStub />
{/if}
</div>
</PopoverContainer>
</div>
<style>
#processing-queue {
--holder-padding: 16px;
position: absolute;
right: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: end;
z-index: 9;
pointer-events: none;
padding: var(--holder-padding);
width: calc(100% - var(--holder-padding) * 2);
}
#processing-queue :global(#processing-popover) {
gap: 12px;
padding: 16px;
padding-bottom: 0;
width: calc(100% - 16px * 2);
max-width: 425px;
}
#processing-header {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 4px;
}
.header-top {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.storage-info {
font-size: 12px;
color: var(--gray);
}
.header-buttons {
display: flex;
flex-direction: row;
gap: var(--padding);
}
.header-buttons button {
font-size: 13px;
font-weight: 500;
padding: 0;
background: none;
box-shadow: none;
text-align: left;
border-radius: 3px;
}
.header-buttons button :global(svg) {
height: 16px;
width: 16px;
}
.clear-button {
color: var(--medium-red);
}
#processing-list {
display: flex;
flex-direction: column;
max-height: 65vh;
overflow-y: scroll;
}
@media screen and (max-width: 535px) {
#processing-queue {
--holder-padding: 8px;
padding-top: 4px;
top: env(safe-area-inset-top);
}
}
</style>

View File

@ -0,0 +1,352 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { formatFileSize } from "$lib/util";
import { downloadFile } from "$lib/download";
import { removeItem } from "$lib/state/queen-bee/queue";
import { savingHandler } from "$lib/api/saving-handler";
import type { CobaltQueueItem } from "$lib/types/queue";
import type { CobaltWorkerProgress } from "$lib/types/workers";
import type { CobaltCurrentTaskItem } from "$lib/types/queen-bee";
import ProgressBar from "$components/queue/ProgressBar.svelte";
import IconX from "@tabler/icons-svelte/IconX.svelte";
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
import IconReload from "@tabler/icons-svelte/IconReload.svelte";
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
import IconExclamationCircle from "@tabler/icons-svelte/IconExclamationCircle.svelte";
import IconMovie from "@tabler/icons-svelte/IconMovie.svelte";
import IconMusic from "@tabler/icons-svelte/IconMusic.svelte";
import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte";
const itemIcons = {
video: IconMovie,
audio: IconMusic,
image: IconPhoto,
};
export let id: string;
export let info: CobaltQueueItem;
export let runningWorker: CobaltCurrentTaskItem | undefined;
export let runningWorkerId: string | undefined;
let retrying = false;
const retry = async (info: CobaltQueueItem) => {
if (info.canRetry && info.originalRequest) {
retrying = true;
await savingHandler({
request: info.originalRequest,
});
retrying = false;
}
};
const download = (file: File) =>
downloadFile({
file: new File([file], info.filename, {
type: info.mimeType,
}),
});
$: progress = runningWorker?.progress;
$: size = formatFileSize(runningWorker?.progress?.size);
type StatusText = {
info: CobaltQueueItem;
runningWorker: CobaltCurrentTaskItem | undefined;
progress: CobaltWorkerProgress | undefined;
size: string;
retrying: boolean;
};
const generateStatusText = ({ info, runningWorker, progress, retrying, size }: StatusText) => {
switch (info.state) {
case "running":
if (runningWorker) {
const running = $t(`queue.state.running.${runningWorker.type}`);
if (progress && progress.percentage) {
return `${running}: ${Math.ceil(progress.percentage)}%, ${size}`;
}
else if (runningWorker && progress && size) {
return `${running}: ${size}`;
}
else if (runningWorker?.type) {
const starting = $t(`queue.state.starting.${runningWorker.type}`);
if (info.pipeline.length > 1) {
const currentPipeline = (info.completedWorkers?.length || 0) + 1;
return `${starting} (${currentPipeline}/${info.pipeline.length})`;
}
return starting;
}
}
return $t("queue.state.starting");
case "done":
return formatFileSize(info.resultFile?.file?.size);
case "error":
return !retrying ? info.errorCode : $t("queue.state.retrying");
case "waiting":
return $t("queue.state.waiting");
}
};
/*
params are passed here because svelte will re-run
the function every time either of them is changed,
which is what we want in this case :3
*/
$: statusText = generateStatusText({
info,
runningWorker,
progress,
retrying,
size,
});
</script>
<div class="processing-item">
<div class="processing-info">
<div class="file-title">
<div class="processing-type">
<svelte:component this={itemIcons[info.mediaType]} />
</div>
<span class="filename">
{info.filename}
</span>
</div>
{#if info.state === "running"}
<div class="progress-holder">
{#each info.pipeline as pipeline}
<ProgressBar
percentage={progress?.percentage}
workerId={pipeline.workerId}
{runningWorkerId}
completedWorkers={info.completedWorkers}
/>
{/each}
</div>
{/if}
<div class="file-status {info.state}" class:retrying>
<div class="status-icon">
{#if info.state === "done"}
<IconCheck />
{/if}
{#if info.state === "error" && !retrying}
<IconExclamationCircle />
{/if}
{#if info.state === "running" || retrying}
<div class="status-spinner">
<IconLoader2 />
</div>
{/if}
</div>
<div class="status-text">
{statusText}
</div>
</div>
</div>
<div class="file-actions">
{#if info.state === "done" && info.resultFile}
<button
class="button action-button"
on:click={() => download(info.resultFile.file)}
>
<IconDownload />
</button>
{/if}
{#if !retrying}
{#if info.state === "error" && info?.canRetry}
<button
class="button action-button"
on:click={() => retry(info)}
>
<IconReload />
</button>
{/if}
<button
class="button action-button"
on:click={() => removeItem(id)}
>
<IconX />
</button>
{/if}
</div>
</div>
<style>
.processing-item,
.file-actions {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
position: relative;
}
.processing-item {
width: 100%;
padding: 8px 0;
gap: 8px;
border-bottom: 1.5px var(--button-elevated) solid;
}
.processing-type {
display: flex;
}
.processing-type :global(svg) {
width: 18px;
height: 18px;
stroke-width: 1.5px;
}
.processing-info {
display: flex;
flex-direction: column;
width: 100%;
font-size: 13px;
gap: 4px;
font-weight: 500;
}
.progress-holder {
display: flex;
flex-direction: row;
gap: 2px;
}
.file-title {
display: flex;
flex-direction: row;
gap: 4px;
line-break: anywhere;
}
.filename {
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
}
.file-status {
font-size: 12px;
color: var(--gray);
line-break: anywhere;
display: flex;
align-items: center;
}
.file-status.error:not(.retrying) {
color: var(--medium-red);
}
.file-status :global(svg) {
width: 16px;
height: 16px;
stroke-width: 2px;
}
.status-icon,
.status-spinner,
.status-text {
display: flex;
}
/*
margin is used instead of gap cuz queued state doesn't have an icon.
margin is applied only to the visible icon, so there's no awkward gap.
*/
.status-icon :global(svg) {
margin-right: 6px;
}
:global([dir="rtl"]) .status-icon :global(svg) {
margin-left: 6px;
margin-right: 0;
}
.status-spinner :global(svg) {
animation: spinner 0.7s infinite linear;
}
.file-actions {
gap: 4px;
}
@media (hover: hover) {
.file-actions {
position: absolute;
right: 0;
background-color: var(--button);
height: 90%;
padding-left: 18px;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s;
mask-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(0, 0, 0, 1) 20%
);
}
:global([dir="rtl"]) .file-actions {
left: 0;
right: unset;
padding-left: 0;
padding-right: 18px;
mask-image: linear-gradient(
-90deg,
rgba(255, 255, 255, 0) 0%,
rgba(0, 0, 0, 1) 20%
);
}
.processing-item:hover .file-actions {
visibility: visible;
opacity: 1;
}
}
@media (hover: none) {
.processing-info {
overflow: hidden;
flex: 1;
}
}
.action-button {
padding: 8px;
height: auto;
box-shadow: none;
}
.action-button :global(svg) {
width: 18px;
height: 18px;
stroke-width: 1.5px;
}
.processing-item:first-child {
padding-top: 0;
}
.processing-item:last-child {
padding-bottom: 16px;
border: none;
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import Meowbalt from "$components/misc/Meowbalt.svelte";
const stubActions = ["download", "remux"];
const randomAction = () => {
return stubActions[Math.floor(Math.random() * stubActions.length)];
};
</script>
<div class="queue-stub">
<Meowbalt emotion="think" />
<span class="subtext stub-text">
{$t("queue.stub", {
value: $t(`queue.stub.${randomAction()}`),
})}
</span>
</div>
<style>
.queue-stub {
--base-padding: calc(var(--padding) * 1.5);
font-size: 13px;
font-weight: 500;
color: var(--gray);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--base-padding);
padding-bottom: calc(var(--base-padding) + 16px);
text-align: center;
gap: var(--padding);
}
.queue-stub :global(.meowbalt) {
height: 120px;
}
.stub-text {
padding: 0;
}
</style>

View File

@ -0,0 +1,133 @@
<script lang="ts">
import IconArrowDown from "@tabler/icons-svelte/IconArrowDown.svelte";
export let indeterminate = false;
export let progress: number = 0;
export let expandAction: () => void;
$: progressStroke = `${progress}, 100`;
const indeterminateStroke = "15, 5";
</script>
<button
id="processing-status"
on:click={expandAction}
class="button"
class:completed={progress >= 100}
>
<svg
id="progress-ring"
class:indeterminate
class:progressive={progress > 0 && !indeterminate}
>
<circle
cx="19"
cy="19"
r="16"
fill="none"
stroke-dasharray={indeterminate
? indeterminateStroke
: progressStroke}
/>
</svg>
<div class="icon-holder">
<IconArrowDown />
</div>
</button>
<style>
#processing-status {
--processing-status-glow: 0 0 8px 0px var(--button-elevated-hover);
pointer-events: all;
padding: 7px;
border-radius: 30px;
box-shadow:
var(--button-box-shadow),
var(--processing-status-glow);
transition: box-shadow 0.2s, background-color 0.2s, transform 0.2s;
}
#processing-status:focus-visible {
box-shadow: 0 0 0 2px var(--white) !important;
}
#processing-status:active {
transform: scale(0.9);
}
#processing-status.completed {
box-shadow:
var(--focus-ring),
var(--processing-status-glow);
}
:global([data-theme="light"]) #processing-status.completed {
background-color: #e0eeff;
}
:global([data-theme="dark"]) #processing-status.completed {
background-color: #1f3249;
}
.icon-holder {
display: flex;
background-color: var(--button-elevated-hover);
padding: 2px;
border-radius: 20px;
transition: background-color 0.2s;
}
.icon-holder :global(svg) {
height: 21px;
width: 21px;
stroke: var(--secondary);
stroke-width: 1.5px;
transition: stroke 0.2s;
}
.completed .icon-holder {
background-color: var(--blue);
}
.completed .icon-holder :global(svg) {
stroke: white;
}
#progress-ring {
position: absolute;
transform: rotate(-90deg);
width: 38px;
height: 38px;
opacity: 0;
transition: opacity 0.2s;
}
#progress-ring circle {
stroke: var(--blue);
stroke-width: 4;
stroke-dashoffset: 0;
}
#progress-ring.progressive circle {
transition: stroke-dasharray 0.2s;
}
#progress-ring.progressive,
#progress-ring.indeterminate {
opacity: 1;
}
#progress-ring.indeterminate {
animation: spinner 3s linear infinite;
}
#progress-ring.indeterminate circle {
transition: none;
}
.completed #progress-ring {
opacity: 0;
}
</style>

View File

@ -0,0 +1,50 @@
<script lang="ts">
import Skeleton from "$components/misc/Skeleton.svelte";
export let percentage: number = 0;
export let workerId: string;
export let runningWorkerId: string | undefined;
export let completedWorkers: string[] = [];
</script>
<div class="file-progress">
{#if percentage && workerId === runningWorkerId}
<div
class="progress"
style="width: {Math.min(100, percentage || 0)}%"
></div>
{:else if completedWorkers?.includes(workerId)}
<div
class="progress"
style="width: 100%"
></div>
{:else if workerId === runningWorkerId}
<Skeleton
height="6px"
width="100%"
class="elevated indeterminate-progress"
/>
{/if}
</div>
<style>
.file-progress {
width: 100%;
background-color: var(--button-elevated);
}
.file-progress,
.file-progress .progress {
height: 6px;
border-radius: 10px;
transition: width 0.1s;
}
.file-progress :global(.indeterminate-progress) {
display: block;
}
.file-progress .progress {
background-color: var(--blue);
}
</style>

View File

@ -10,7 +10,9 @@
import dialogs from "$lib/state/dialogs"; import dialogs from "$lib/state/dialogs";
import { link } from "$lib/state/omnibox"; import { link } from "$lib/state/omnibox";
import { hapticSwitch } from "$lib/haptics";
import { updateSetting } from "$lib/state/settings"; import { updateSetting } from "$lib/state/settings";
import { savingHandler } from "$lib/api/saving-handler";
import { pasteLinkFromClipboard } from "$lib/clipboard"; import { pasteLinkFromClipboard } from "$lib/clipboard";
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile"; import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
@ -65,6 +67,8 @@
return; return;
} }
hapticSwitch();
const pastedData = await pasteLinkFromClipboard(); const pastedData = await pasteLinkFromClipboard();
if (!pastedData) return; if (!pastedData) return;
@ -75,7 +79,7 @@
if (!isBotCheckOngoing) { if (!isBotCheckOngoing) {
await tick(); // wait for button to render await tick(); // wait for button to render
downloadButton.download($link); savingHandler({ url: $link });
} }
} }
}; };
@ -94,7 +98,7 @@
} }
if (e.key === "Enter" && validLink($link) && isFocused) { if (e.key === "Enter" && validLink($link) && isFocused) {
downloadButton.download($link); savingHandler({ url: $link });
} }
if (["Escape", "Clear"].includes(e.key) && isFocused) { if (["Escape", "Clear"].includes(e.key) && isFocused) {
@ -217,7 +221,7 @@
flex-direction: column; flex-direction: column;
max-width: 640px; max-width: 640px;
width: 100%; width: 100%;
gap: 8px; gap: 7px;
} }
#input-container { #input-container {
@ -242,8 +246,8 @@
} }
#input-container.focused { #input-container.focused {
box-shadow: 0 0 0 1.5px var(--secondary) inset; box-shadow: 0 0 0 1px var(--secondary) inset;
outline: var(--secondary) 0.5px solid; outline: var(--secondary) 1px solid;
} }
#input-container.focused :global(#input-icons svg) { #input-container.focused :global(#input-icons svg) {

View File

@ -3,10 +3,39 @@
import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte"; import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte";
export let loading: boolean; export let loading: boolean;
export let animated = !!loading;
/*
initial spinner state is equal to loading state,
just so it's animated on init (or not).
on transition start, it overrides the value
to start spinning (to prevent zooming in with no spinning).
then, on transition end, when the spinner is hidden,
and if loading state is false, the class is removed
and the spinner doesn't spin in background while being invisible.
if loading state is true, then it will just stay spinning
(aka when it's visible and should be spinning).
the spin on transition start is needed for the whirlpool effect
of the link icon being sucked into the spinner.
this may be unnecessarily complicated but i think it looks neat.
*/
</script> </script>
<div id="input-icons" class:loading> <div id="input-icons" class:loading>
<div class="input-icon spinner-icon"> <div
class="input-icon spinner-icon"
class:animated
on:transitionstart={() => {
animated = true;
}}
on:transitionend={() => {
animated = !!loading;
}}
>
<IconLoader2 /> <IconLoader2 />
</div> </div>
<div class="input-icon link-icon"> <div class="input-icon link-icon">
@ -49,12 +78,12 @@
opacity: 0; opacity: 0;
} }
.spinner-icon :global(svg) { .spinner-icon.animated :global(svg) {
animation: spin 0.7s infinite linear; animation: spinner 0.7s infinite linear;
} }
.loading .link-icon :global(svg) { .loading .link-icon :global(svg) {
animation: spin 0.7s infinite linear; animation: spinner 0.7s linear;
} }
.loading .link-icon { .loading .link-icon {
@ -66,13 +95,4 @@
transform: none; transform: none;
opacity: 1; opacity: 1;
} }
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style> </style>

View File

@ -1,18 +1,21 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { getServerInfo } from "$lib/api/server-info";
import cachedInfo from "$lib/state/server-info"; import cachedInfo from "$lib/state/server-info";
import { getServerInfo } from "$lib/api/server-info";
import type { SvelteComponent } from "svelte";
import Skeleton from "$components/misc/Skeleton.svelte"; import Skeleton from "$components/misc/Skeleton.svelte";
import IconPlus from "@tabler/icons-svelte/IconPlus.svelte"; import IconPlus from "@tabler/icons-svelte/IconPlus.svelte";
import PopoverContainer from "$components/misc/PopoverContainer.svelte";
let services: string[] = []; let services: string[] = [];
let popover: HTMLDivElement; let popover: SvelteComponent;
$: expanded = false; $: expanded = false;
let servicesContainer: HTMLDivElement;
$: loaded = false; $: loaded = false;
$: renderPopover = false;
const loadInfo = async () => { const loadInfo = async () => {
await getServerInfo(); await getServerInfo();
@ -29,19 +32,7 @@
await loadInfo(); await loadInfo();
} }
if (expanded) { if (expanded) {
popover.focus(); servicesContainer.focus();
}
}
const showPopover = async () => {
const timeout = !renderPopover;
renderPopover = true;
// 10ms delay to let the popover render for the first time
if (timeout) {
setTimeout(popoverAction, 10);
} else {
await popoverAction();
} }
}; };
</script> </script>
@ -49,7 +40,8 @@
<div id="supported-services" class:expanded> <div id="supported-services" class:expanded>
<button <button
id="services-button" id="services-button"
on:click={showPopover} class="button"
on:click={popoverAction}
aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)} aria-label={$t(`save.services.title_${expanded ? "hide" : "show"}`)}
> >
<div class="expand-icon"> <div class="expand-icon">
@ -58,33 +50,35 @@
<span class="title">{$t("save.services.title")}</span> <span class="title">{$t("save.services.title")}</span>
</button> </button>
{#if renderPopover} <PopoverContainer
<div id="services-popover"> bind:this={popover}
<div id="services-popover"
id="services-container" {expanded}
bind:this={popover} >
tabindex="-1" <div
data-focus-ring-hidden id="services-container"
> bind:this={servicesContainer}
{#if loaded} tabindex="-1"
{#each services as service} data-focus-ring-hidden
<div class="service-item">{service}</div> >
{/each} {#if loaded}
{:else} {#each services as service}
{#each { length: 17 } as _} <div class="service-item">{service}</div>
<Skeleton {/each}
class="elevated" {:else}
width={Math.random() * 44 + 50 + "px"} {#each { length: 17 } as _}
height="24.5px" <Skeleton
/> class="elevated"
{/each} width={Math.random() * 44 + 50 + "px"}
{/if} height="24.5px"
</div> />
<div id="services-disclaimer" class="subtext"> {/each}
{$t("save.services.disclaimer")} {/if}
</div>
</div> </div>
{/if} <div id="services-disclaimer" class="subtext">
{$t("save.services.disclaimer")}
</div>
</PopoverContainer>
</div> </div>
<style> <style>
@ -97,34 +91,6 @@
height: 35px; height: 35px;
} }
#services-popover {
display: flex;
flex-direction: column;
border-radius: 18px;
background: var(--button);
box-shadow:
var(--button-box-shadow),
0 0 10px 10px var(--popover-glow);
position: relative;
padding: 12px;
gap: 6px;
top: 6px;
opacity: 0;
transform: scale(0);
transform-origin: top center;
transition:
transform 0.3s cubic-bezier(0.53, 0.05, 0.23, 1.15),
opacity 0.25s cubic-bezier(0.53, 0.05, 0.23, 0.99);
}
.expanded #services-popover {
transform: scale(1);
opacity: 1;
}
#services-button { #services-button {
gap: 9px; gap: 9px;
padding: 7px 13px 7px 10px; padding: 7px 13px 7px 10px;
@ -135,9 +101,10 @@
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
background: none; background: none;
transition: background 0.2s, box-shadow 0.1s;
} }
#services-button:not(:focus-visible) { #services-button:not(:focus-visible):not(:active) {
box-shadow: none; box-shadow: none;
} }
@ -151,19 +118,37 @@
background: var(--button-elevated); background: var(--button-elevated);
padding: 0; padding: 0;
box-shadow: none; box-shadow: none;
transition: transform 0.2s; transition: background 0.2s, transform 0.2s;
} }
#services-button:active .expand-icon { #services-button:active {
background: var(--button-elevated-hover); background: var(--button-hover-transparent);
} }
@media (hover: hover) { @media (hover: hover) {
#services-button:hover {
background: var(--button-hover-transparent);
}
#services-button:active {
background: var(--button-press-transparent);
}
#services-button:hover .expand-icon { #services-button:hover .expand-icon {
background: var(--button-elevated-hover); background: var(--button-elevated-hover);
} }
} }
@media (hover: none) {
#services-button:active {
box-shadow: none;
}
}
#services-button:active .expand-icon {
background: var(--button-elevated-press);
}
.expand-icon :global(svg) { .expand-icon :global(svg) {
height: 18px; height: 18px;
width: 18px; width: 18px;

View File

@ -7,6 +7,7 @@
<button <button
id="clear-button" id="clear-button"
class="button"
on:click={click} on:click={click}
aria-label={$t("a11y.save.clear_input")} aria-label={$t("a11y.save.clear_input")}
> >

View File

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import "@fontsource-variable/noto-sans-mono"; import "@fontsource-variable/noto-sans-mono";
import API from "$lib/api/api"; import { onDestroy } from "svelte";
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { createDialog } from "$lib/state/dialogs"; import { hapticSwitch } from "$lib/haptics";
import { downloadFile } from "$lib/download"; import { savingHandler } from "$lib/api/saving-handler";
import { downloadButtonState } from "$lib/state/omnibox";
import type { DialogInfo } from "$lib/types/dialog"; import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
export let url: string; export let url: string;
export let disabled = false; export let disabled = false;
@ -15,148 +16,50 @@
$: buttonText = ">>"; $: buttonText = ">>";
$: buttonAltText = $t("a11y.save.download"); $: buttonAltText = $t("a11y.save.download");
let defaultErrorPopup: DialogInfo = {
id: "save-error",
type: "small",
meowbalt: "error",
buttons: [
{
text: $t("button.gotit"),
main: true,
action: () => {},
},
],
};
type DownloadButtonState = "idle" | "think" | "check" | "done" | "error"; type DownloadButtonState = "idle" | "think" | "check" | "done" | "error";
const changeDownloadButton = (state: DownloadButtonState) => { const unsubscribe = downloadButtonState.subscribe(
disabled = state !== "idle"; (state: CobaltDownloadButtonState) => {
loading = state === "think" || state === "check"; disabled = state !== "idle";
loading = state === "think" || state === "check";
buttonText = { buttonText = {
idle: ">>", idle: ">>",
think: "...", think: "...",
check: "..?", check: "..?",
done: ">>>", done: ">>>",
error: "!!", error: "!!",
}[state]; }[state];
buttonAltText = $t( buttonAltText = $t(
{
idle: "a11y.save.download",
think: "a11y.save.download.think",
check: "a11y.save.download.check",
done: "a11y.save.download.done",
error: "a11y.save.download.error",
}[state]
);
// states that don't wait for anything, and thus can
// transition back to idle after some period of time.
const final: DownloadButtonState[] = ["done", "error"];
if (final.includes(state)) {
setTimeout(() => changeDownloadButton("idle"), 1500);
}
};
export const download = async (link: string) => {
changeDownloadButton("think");
const response = await API.request(link);
if (!response) {
changeDownloadButton("error");
return createDialog({
...defaultErrorPopup,
bodyText: $t("error.api.unreachable"),
});
}
if (response.status === "error") {
changeDownloadButton("error");
return createDialog({
...defaultErrorPopup,
bodyText: $t(response.error.code, response?.error?.context),
});
}
if (response.status === "redirect") {
changeDownloadButton("done");
return downloadFile({
url: response.url,
urlType: "redirect",
});
}
if (response.status === "tunnel") {
changeDownloadButton("check");
const probeResult = await API.probeCobaltTunnel(response.url);
if (probeResult === 200) {
changeDownloadButton("done");
return downloadFile({
url: response.url,
});
} else {
changeDownloadButton("error");
return createDialog({
...defaultErrorPopup,
bodyText: $t("error.tunnel.probe"),
});
}
}
if (response.status === "picker") {
changeDownloadButton("done");
const buttons = [
{ {
text: $t("button.done"), idle: "a11y.save.download",
main: true, think: "a11y.save.download.think",
action: () => {}, check: "a11y.save.download.check",
}, done: "a11y.save.download.done",
]; error: "a11y.save.download.error",
}[state]
);
if (response.audio) { // states that don't wait for anything, and thus can
const pickerAudio = response.audio; // transition back to idle after some period of time.
buttons.unshift({ const final: DownloadButtonState[] = ["done", "error"];
text: $t("button.download.audio"), if (final.includes(state)) {
main: false, setTimeout(() => downloadButtonState.set("idle"), 1500);
action: () => {
downloadFile({
url: pickerAudio,
});
},
});
} }
return createDialog({
id: "download-picker",
type: "picker",
items: response.picker,
buttons,
});
} }
);
changeDownloadButton("error"); onDestroy(() => unsubscribe());
return createDialog({
...defaultErrorPopup,
bodyText: $t("error.api.unknown_response"),
});
};
</script> </script>
<button <button
id="download-button" id="download-button"
{disabled} {disabled}
on:click={() => download(url)} on:click={() => {
hapticSwitch();
savingHandler({ url });
}}
aria-label={buttonAltText} aria-label={buttonAltText}
> >
<span id="download-state">{buttonText}</span> <span id="download-state">{buttonText}</span>
@ -170,9 +73,12 @@
height: 100%; height: 100%;
min-width: 48px; min-width: 48px;
width: 48px;
border-radius: 0; border-radius: 0;
padding: 0 12px;
/* visually align the button, +1.5px because of inset box-shadow on parent */
padding: 0 13.5px 0 12px;
background: none; background: none;
box-shadow: none; box-shadow: none;
@ -194,7 +100,7 @@
} }
#download-button:focus-visible { #download-button:focus-visible {
box-shadow: 0 0 0 2px var(--blue) inset; box-shadow: var(--focus-ring);
} }
#download-state { #download-state {
@ -212,7 +118,7 @@
#download-button:disabled { #download-button:disabled {
cursor: unset; cursor: unset;
opacity: 0.7; color: var(--gray);
} }
:global(#input-container.focused) #download-button { :global(#input-container.focused) #download-button {
@ -225,11 +131,12 @@
} }
@media (hover: hover) { @media (hover: hover) {
#download-button:hover { #download-button:hover:not(:disabled) {
background: var(--button-hover-transparent); background: var(--button-hover-transparent);
} }
#download-button:disabled:hover { }
background: none;
} #download-button:active:not(:disabled) {
background: var(--button-press-transparent);
} }
</style> </style>

View File

@ -0,0 +1,42 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { createDialog } from "$lib/state/dialogs";
import { clearQueue } from "$lib/state/queen-bee/queue";
import { clearCacheStorage, clearFileStorage } from "$lib/storage";
import IconFileShredder from "@tabler/icons-svelte/IconFileShredder.svelte";
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
const clearDialog = () => {
createDialog({
id: "wipe-confirm",
type: "small",
icon: "warn-red",
title: $t("dialog.clear_cache.title"),
bodyText: $t("dialog.clear_cache.body"),
buttons: [
{
text: $t("button.cancel"),
main: false,
action: () => {},
},
{
text: $t("button.clear"),
color: "red",
main: true,
timeout: 2000,
action: async () => {
clearQueue();
await clearFileStorage();
await clearCacheStorage();
},
},
],
});
};
</script>
<DataSettingsButton id="clear-cache" click={clearDialog} danger>
<IconFileShredder />
{$t("button.clear_cache")}
</DataSettingsButton>

View File

@ -0,0 +1,32 @@
<script lang="ts">
export let id: string;
export let click: () => void;
export let danger = false;
</script>
<button {id} class="button data-button" class:danger on:click={click}>
<slot></slot>
</button>
<style>
.data-button {
padding: 8px 14px;
width: max-content;
text-align: start;
}
.data-button :global(svg) {
stroke-width: 1.8px;
height: 21px;
width: 21px;
}
.data-button.danger {
background-color: var(--red);
color: var(--white);
}
.data-button.danger:hover {
background-color: var(--dark-red);
}
</style>

View File

@ -106,12 +106,16 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 8px; gap: 9px;
padding: 8px var(--padding); padding: 7px var(--padding);
} }
.filename-preview-item:first-child { .filename-preview-item:first-child {
border-bottom: 1.5px var(--button-stroke) solid; border-bottom: 1px var(--button-stroke) solid;
}
.filename-preview-item:last-child {
padding-top: 6px;
} }
.item-icon { .item-icon {
@ -144,6 +148,7 @@
.item-text .description { .item-text .description {
padding: 0; padding: 0;
line-height: 1.3;
} }
@media screen and (max-width: 750px) { @media screen and (max-width: 750px) {

View File

@ -5,7 +5,7 @@
import { validateSettings } from "$lib/settings/validate"; import { validateSettings } from "$lib/settings/validate";
import { storedSettings, updateSetting, loadFromString } from "$lib/state/settings"; import { storedSettings, updateSetting, loadFromString } from "$lib/state/settings";
import ActionButton from "$components/buttons/ActionButton.svelte"; import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
import ResetSettingsButton from "$components/settings/ResetSettingsButton.svelte"; import ResetSettingsButton from "$components/settings/ResetSettingsButton.svelte";
import IconFileExport from "@tabler/icons-svelte/IconFileExport.svelte"; import IconFileExport from "@tabler/icons-svelte/IconFileExport.svelte";
@ -95,16 +95,16 @@
</script> </script>
<div class="button-row" id="settings-data-transfer"> <div class="button-row" id="settings-data-transfer">
<ActionButton id="import-settings" click={importSettings}> <DataSettingsButton id="import-settings" click={importSettings}>
<IconFileImport /> <IconFileImport />
{$t("button.import")} {$t("button.import")}
</ActionButton> </DataSettingsButton>
{#if $storedSettings.schemaVersion} {#if $storedSettings.schemaVersion}
<ActionButton id="export-settings" click={exportSettings}> <DataSettingsButton id="export-settings" click={exportSettings}>
<IconFileExport /> <IconFileExport />
{$t("button.export")} {$t("button.export")}
</ActionButton> </DataSettingsButton>
{/if} {/if}
{#if $storedSettings.schemaVersion} {#if $storedSettings.schemaVersion}

View File

@ -3,15 +3,16 @@
import { createDialog } from "$lib/state/dialogs"; import { createDialog } from "$lib/state/dialogs";
import { resetSettings } from "$lib/state/settings"; import { resetSettings } from "$lib/state/settings";
import IconTrash from "@tabler/icons-svelte/IconTrash.svelte"; import IconRestore from "@tabler/icons-svelte/IconRestore.svelte";
import DataSettingsButton from "$components/settings/DataSettingsButton.svelte";
const resetDialog = () => { const resetDialog = () => {
createDialog({ createDialog({
id: "wipe-confirm", id: "wipe-confirm",
type: "small", type: "small",
icon: "warn-red", icon: "warn-red",
title: $t("dialog.reset.title"), title: $t("dialog.reset_settings.title"),
bodyText: $t("dialog.reset.body"), bodyText: $t("dialog.reset_settings.body"),
buttons: [ buttons: [
{ {
text: $t("button.cancel"), text: $t("button.cancel"),
@ -30,26 +31,7 @@
}; };
</script> </script>
<button id="setting-button-reset" class="button" on:click={resetDialog}> <DataSettingsButton id="reset-settings" click={resetDialog} danger>
<IconTrash /> <IconRestore />
{$t("button.reset")} {$t("button.reset")}
</button> </DataSettingsButton>
<style>
#setting-button-reset {
background-color: var(--red);
color: var(--white);
width: max-content;
text-align: start;
}
#setting-button-reset:hover {
background-color: var(--dark-red);
}
#setting-button-reset :global(svg) {
stroke-width: 2px;
height: 24px;
width: 24px;
}
</style>

View File

@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import { copyURL as _copyURL } from "$lib/download";
import SectionHeading from "$components/misc/SectionHeading.svelte"; import SectionHeading from "$components/misc/SectionHeading.svelte";
export let title: string; export let title: string;

View File

@ -8,6 +8,7 @@
import { updateSetting } from "$lib/state/settings"; import { updateSetting } from "$lib/state/settings";
import type { CobaltSettings } from "$lib/types/settings"; import type { CobaltSettings } from "$lib/types/settings";
import { hapticConfirm, hapticSwitch } from "$lib/haptics";
import IconSelector from "@tabler/icons-svelte/IconSelector.svelte"; import IconSelector from "@tabler/icons-svelte/IconSelector.svelte";
export let title: string; export let title: string;
@ -22,8 +23,9 @@
export let disabled = false; export let disabled = false;
const onChange = (event: Event) => { const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement; hapticConfirm();
const target = event.target as HTMLSelectElement;
updateSetting({ updateSetting({
[settingContext]: { [settingContext]: {
[settingId]: target.value, [settingId]: target.value,
@ -46,13 +48,17 @@
</div> </div>
</div> </div>
<select on:change={e => onChange(e)} {disabled}> <select
on:click={() => hapticSwitch()}
on:change={(e) => onChange(e)}
{disabled}
>
{#each Object.keys(items) as value, i} {#each Object.keys(items) as value, i}
<option {value} selected={selectedOption === value}> <option {value} selected={selectedOption === value}>
{items[value]} {items[value]}
</option> </option>
{#if i === 0} {#if i === 0}
<hr> <hr />
{/if} {/if}
{/each} {/each}
</select> </select>
@ -157,10 +163,4 @@
background: initial; background: initial;
border: initial; border: initial;
} }
@media (hover: hover) {
.selector:hover {
background-color: var(--button-hover);
}
}
</style> </style>

View File

@ -14,25 +14,59 @@
import IconX from "@tabler/icons-svelte/IconX.svelte"; import IconX from "@tabler/icons-svelte/IconX.svelte";
import IconCheck from "@tabler/icons-svelte/IconCheck.svelte"; import IconCheck from "@tabler/icons-svelte/IconCheck.svelte";
import IconArrowBack from "@tabler/icons-svelte/IconArrowBack.svelte";
import IconEye from "@tabler/icons-svelte/IconEye.svelte";
import IconEyeClosed from "@tabler/icons-svelte/IconEyeClosed.svelte";
type SettingsInputType = "url" | "uuid";
export let settingId: Id; export let settingId: Id;
export let settingContext: Context; export let settingContext: Context;
export let placeholder: string; export let placeholder: string;
export let altText: string; export let altText: string;
export let type: "url" | "uuid" = "url"; export let type: "url" | "uuid" = "url";
export let sensitive = false;
export let showInstanceWarning = false; export let showInstanceWarning = false;
const regex = { const regex = {
url: "https?:\\/\\/[a-z0-9.\\-]+(:\\d+)?/?",
uuid: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", uuid: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
}; };
let input: HTMLInputElement; let input: HTMLInputElement;
let inputValue: string = String(get(settings)[settingContext][settingId]); let inputValue: string = String(get(settings)[settingContext][settingId]);
let inputFocused = false; let inputFocused = false;
let validInput = false; let validInput = true;
const writeToSettings = (value: string, type: "url" | "uuid" | "text") => { let inputHidden = true;
$: inputType = sensitive && inputHidden ? "password" : "text";
const checkInput = () => {
// mark input as valid if it's empty to allow wiping
if (inputValue.length === 0) {
validInput = true;
return;
}
if (type === "url") {
try {
new URL(inputValue)?.origin?.toString();
validInput = true;
return;
} catch {
validInput = false;
return;
}
} else {
validInput = new RegExp(regex[type]).test(inputValue);
}
}
const writeToSettings = (value: string, type: SettingsInputType) => {
// we assume that the url is valid and error can't be thrown here
// since it was tested before by checkInput()
updateSetting({ updateSetting({
[settingContext]: { [settingContext]: {
[settingId]: [settingId]:
@ -46,8 +80,9 @@
if (showInstanceWarning) { if (showInstanceWarning) {
await customInstanceWarning(); await customInstanceWarning();
if ($settings.processing.seenCustomWarning && inputValue) { if ($settings.processing.seenCustomWarning) {
return writeToSettings(inputValue, type); // fall back to uuid to allow writing empty strings
return writeToSettings(inputValue, inputValue ? type : "uuid");
} }
return; return;
@ -58,49 +93,89 @@
</script> </script>
<div id="settings-input-holder"> <div id="settings-input-holder">
<div id="input-container" class:focused={inputFocused} aria-hidden="false"> <div
id="input-container"
class:focused={inputFocused}
aria-hidden="false"
>
<input <input
id="input-box" class="input-box"
bind:this={input} bind:this={input}
bind:value={inputValue} bind:value={inputValue}
on:input={() => (validInput = input.checkValidity())} on:input={() => {
on:input={() => (inputFocused = true)} inputFocused = true;
checkInput();
}}
on:focus={() => (inputFocused = true)} on:focus={() => (inputFocused = true)}
on:blur={() => (inputFocused = false)} on:blur={() => (inputFocused = false)}
spellcheck="false" spellcheck="false"
autocomplete="off" autocomplete="off"
autocapitalize="off" autocapitalize="off"
maxlength="64" maxlength="64"
pattern={regex[type]}
aria-label={altText} aria-label={altText}
aria-hidden="false" aria-hidden="false"
aria-invalid={!validInput}
{...{ type: inputType }}
/> />
{#if inputValue.length > 0}
<button
class="button input-inner-button"
on:click={() => {
inputValue = "";
checkInput();
}}
aria-label={$t("button.clear_input")}
>
<IconX />
</button>
{#if sensitive}
<button
class="button input-inner-button"
on:click={() => (inputHidden = !inputHidden)}
aria-label={$t(
inputHidden ? "button.show_input" : "button.hide_input"
)}
>
{#if inputHidden}
<IconEye />
{:else}
<IconEyeClosed />
{/if}
</button>
{/if}
{/if}
{#if inputValue.length === 0} {#if inputValue.length === 0}
<span class="input-placeholder" aria-hidden="true"> <span class="input-placeholder" aria-hidden="true">
{placeholder} {placeholder}
</span> </span>
{#if String($settings[settingContext][settingId]).length > 0}
<button
class="button input-inner-button"
on:click={() => {
inputValue = String($settings[settingContext][settingId]);
checkInput();
}}
aria-label={$t("button.restore_input")}
>
<IconArrowBack />
</button>
{/if}
{/if} {/if}
</div> </div>
<div id="settings-input-buttons"> <div id="settings-input-buttons">
<button <button
class="settings-input-button" class="button settings-input-button"
aria-label={$t("button.save")} aria-label={$t("button.save")}
disabled={inputValue == $settings[settingContext][settingId] || !validInput} disabled={inputValue === $settings[settingContext][settingId] || !validInput}
on:click={save} on:click={save}
> >
<IconCheck /> <IconCheck />
</button> </button>
<button
class="settings-input-button"
aria-label={$t("button.reset")}
disabled={String($settings[settingContext][settingId]).length <= 0}
on:click={() => writeToSettings("", "text")}
>
<IconX />
</button>
</div> </div>
</div> </div>
@ -111,7 +186,8 @@
} }
#input-container { #input-container {
padding: 0 18px; padding: 0 16px;
padding-right: 4px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
color: var(--secondary); color: var(--secondary);
background-color: var(--button); background-color: var(--button);
@ -124,26 +200,20 @@
} }
#input-container, #input-container,
#input-box { .input-box {
font-size: 13.5px; font-size: 13px;
font-weight: 500; font-weight: 500;
min-width: 0; min-width: 0;
} }
#input-box { .input-box {
flex: 1; flex: 1;
background-color: transparent; background-color: transparent;
color: var(--secondary); color: var(--secondary);
border: none; border: none;
padding-block: 0; padding-block: 0;
padding-inline: 0; padding-inline: 0;
padding: 12px 0; padding: 11.5px 0;
}
#input-box::placeholder {
color: var(--gray);
/* fix for firefox */
opacity: 1;
} }
.input-placeholder { .input-placeholder {
@ -153,7 +223,7 @@
white-space: nowrap; white-space: nowrap;
} }
#input-box:focus-visible { .input-box:focus-visible {
box-shadow: unset !important; box-shadow: unset !important;
} }
@ -168,19 +238,38 @@
} }
.settings-input-button { .settings-input-button {
height: 42px; width: 40px;
width: 42px;
padding: 0; padding: 0;
} }
.settings-input-button :global(svg) { .settings-input-button :global(svg) {
height: 21px; height: 21px;
width: 21px; width: 21px;
stroke-width: 1.5px; stroke-width: 1.8px;
} }
.settings-input-button[disabled] { .settings-input-button[disabled] {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
.input-inner-button {
height: 34px;
width: 34px;
padding: 0;
box-shadow: none;
/* 4px is padding outside of the button */
border-radius: calc(var(--border-radius) - 4px);
z-index: 1;
}
.input-inner-button :global(svg) {
height: 18px;
width: 18px;
stroke-width: 1.8px;
}
:global(svg) {
will-change: transform;
}
</style> </style>

View File

@ -11,10 +11,14 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: calc(var(--padding) * 2); padding: calc(var(--sidebar-tab-padding) * 2);
/* accommodate space for scaling animation */ /* accommodate space for scaling animation */
padding-bottom: calc(var(--padding) * 2 - var(--sidebar-inner-padding)); padding-bottom: calc(var(--sidebar-tab-padding) * 2 - var(--sidebar-inner-padding));
}
#cobalt-logo :global(path) {
fill: var(--sidebar-highlight);
} }
@media screen and (max-width: 535px) { @media screen and (max-width: 535px) {

View File

@ -60,7 +60,7 @@
height: 100%; height: 100%;
justify-content: space-between; justify-content: space-between;
padding: var(--sidebar-inner-padding); padding: var(--sidebar-inner-padding);
padding-bottom: var(--border-radius); padding-bottom: var(--sidebar-tab-padding);
overflow-y: scroll; overflow-y: scroll;
} }
@ -79,6 +79,7 @@
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
z-index: 3; z-index: 3;
padding: var(--sidebar-inner-padding) 0;
} }
#sidebar::before { #sidebar::before {
@ -95,27 +96,26 @@
#sidebar-tabs { #sidebar-tabs {
overflow-y: visible; overflow-y: visible;
overflow-x: scroll; overflow-x: scroll;
padding-bottom: 0; padding: 0;
padding: var(--sidebar-inner-padding) 0;
height: fit-content; height: fit-content;
} }
#sidebar :global(.sidebar-inner-container:first-child) { #sidebar :global(.sidebar-inner-container:first-child) {
padding-left: calc(var(--border-radius) * 2); padding-left: calc(var(--border-radius) * 1.5);
} }
#sidebar :global(.sidebar-inner-container:last-child) { #sidebar :global(.sidebar-inner-container:last-child) {
padding-right: calc(var(--border-radius) * 2); padding-right: calc(var(--border-radius) * 1.5);
} }
#sidebar :global(.sidebar-inner-container:first-child:dir(rtl)) { #sidebar :global(.sidebar-inner-container:first-child:dir(rtl)) {
padding-left: 0; padding-left: 0;
padding-right: calc(var(--border-radius) * 2); padding-right: calc(var(--border-radius) * 1.5);
} }
#sidebar :global(.sidebar-inner-container:last-child:dir(rtl)) { #sidebar :global(.sidebar-inner-container:last-child:dir(rtl)) {
padding-right: 0; padding-right: 0;
padding-left: calc(var(--border-radius) * 2); padding-left: calc(var(--border-radius) * 1.5);
} }
} }

View File

@ -48,7 +48,7 @@
{/if} {/if}
<svelte:component this={icon} /> <svelte:component this={icon} />
{$t(`tabs.${name}`)} <span class="tab-title">{$t(`tabs.${name}`)}</span>
</a> </a>
<style> <style>
@ -58,7 +58,7 @@
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 3px; gap: 3px;
padding: var(--padding) 3px; padding: var(--sidebar-tab-padding) 3px;
color: var(--sidebar-highlight); color: var(--sidebar-highlight);
font-size: var(--sidebar-font-size); font-size: var(--sidebar-font-size);
opacity: 0.75; opacity: 0.75;
@ -108,6 +108,14 @@
opacity: 0.7; opacity: 0.7;
} }
.tab-title {
white-space: nowrap;
}
.sidebar-tab:active:not(.active) {
opacity: 1;
}
@keyframes pressButton { @keyframes pressButton {
0% { 0% {
transform: scale(0.9); transform: scale(0.9);
@ -121,14 +129,23 @@
} }
@media (hover: hover) { @media (hover: hover) {
.sidebar-tab:active:not(.active) { .sidebar-tab:hover:not(.active) {
opacity: 1; background-color: var(--button-hover-transparent);
background-color: var(--sidebar-hover); }
.sidebar-tab:active:not(.active),
.sidebar-tab:focus:hover:not(.active) {
background-color: var(--button-press-transparent);
} }
.sidebar-tab:hover:not(.active) { .sidebar-tab:hover:not(.active) {
opacity: 1; opacity: 1;
background-color: var(--sidebar-hover); }
.sidebar-tab:active:not(.active),
.sidebar-tab:focus:hover:not(.active) {
opacity: 1;
box-shadow: 0 0 0 1px var(--sidebar-stroke) inset;
} }
} }

View File

@ -6,7 +6,7 @@
export let path: string; export let path: string;
export let title: string; export let title: string;
export let icon: ConstructorOfATypedSvelteComponent; export let icon: ConstructorOfATypedSvelteComponent;
export let iconColor: "gray" | "blue" | "green" = "gray"; export let iconColor: "gray" | "blue" | "green" | "magenta" | "purple" | "orange" = "gray";
$: isActive = $page.url.pathname === path; $: isActive = $page.url.pathname === path;
</script> </script>
@ -17,8 +17,8 @@
class:active={isActive} class:active={isActive}
role="button" role="button"
> >
<div class="subnav-tab-left"> <div class="subnav-tab-left" style="--icon-color: var(--{iconColor})">
<div class="tab-icon" style="background: var(--{iconColor})"> <div class="tab-icon">
<svelte:component this={icon} /> <svelte:component this={icon} />
</div> </div>
<div class="subnav-tab-text"> <div class="subnav-tab-text">
@ -41,7 +41,6 @@
gap: calc(var(--small-padding) * 2); gap: calc(var(--small-padding) * 2);
padding: var(--big-padding); padding: var(--big-padding);
font-weight: 500; font-weight: 500;
background: var(--primary);
color: var(--button-text); color: var(--button-text);
border-radius: var(--border-radius); border-radius: var(--border-radius);
overflow: hidden; overflow: hidden;
@ -66,6 +65,7 @@
align-items: center; align-items: center;
padding: var(--small-padding); padding: var(--small-padding);
border-radius: 5px; border-radius: 5px;
background: var(--icon-color);
} }
.subnav-tab .tab-icon :global(svg) { .subnav-tab .tab-icon :global(svg) {
@ -75,6 +75,19 @@
width: 20px; width: 20px;
} }
.subnav-tab:not(.active) .tab-icon {
background: rgba(0, 0, 0, 0.05);
box-shadow: var(--button-box-shadow);
}
:global([data-theme="dark"]) .subnav-tab:not(.active) .tab-icon {
background: rgba(255, 255, 255, 0.1);
}
.subnav-tab:not(.active) .tab-icon :global(svg) {
stroke: var(--icon-color);
}
.subnav-tab-chevron :global(svg) { .subnav-tab-chevron :global(svg) {
display: none; display: none;
stroke-width: 2px; stroke-width: 2px;
@ -93,8 +106,10 @@
} }
} }
.subnav-tab:active { .subnav-tab:active,
background: var(--button-hover-transparent); .subnav-tab:focus:hover:not(.active) {
background: var(--button-press-transparent);
box-shadow: var(--button-box-shadow);
} }
.subnav-tab.active { .subnav-tab.active {
@ -118,7 +133,7 @@
.subnav-tab:not(:last-child) { .subnav-tab:not(:last-child) {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
box-shadow: 48px 3px 0px -1.8px var(--button-stroke); box-shadow: 48px 3px 0px -2px var(--button-stroke);
} }
.subnav-tab:not(:first-child) { .subnav-tab:not(:first-child) {

View File

@ -1,7 +1,6 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import settings from "$lib/state/settings"; import settings from "$lib/state/settings";
import lazySettingGetter from "$lib/settings/lazy-get";
import { getSession } from "$lib/api/session"; import { getSession } from "$lib/api/session";
import { currentApiURL } from "$lib/api/api-url"; import { currentApiURL } from "$lib/api/api-url";
@ -10,7 +9,7 @@ import cachedInfo from "$lib/state/server-info";
import { getServerInfo } from "$lib/api/server-info"; import { getServerInfo } from "$lib/api/server-info";
import type { Optional } from "$lib/types/generic"; import type { Optional } from "$lib/types/generic";
import type { CobaltAPIResponse, CobaltErrorResponse } from "$lib/types/api"; import type { CobaltAPIResponse, CobaltErrorResponse, CobaltSaveRequestBody } from "$lib/types/api";
const getAuthorization = async () => { const getAuthorization = async () => {
const processing = get(settings).processing; const processing = get(settings).processing;
@ -43,31 +42,7 @@ const getAuthorization = async () => {
} }
} }
const request = async (url: string) => { const request = async (request: CobaltSaveRequestBody) => {
const getSetting = lazySettingGetter(get(settings));
const request = {
url,
downloadMode: getSetting("save", "downloadMode"),
audioBitrate: getSetting("save", "audioBitrate"),
audioFormat: getSetting("save", "audioFormat"),
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
youtubeDubLang: getSetting("save", "youtubeDubLang"),
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
videoQuality: getSetting("save", "videoQuality"),
youtubeHLS: getSetting("save", "youtubeHLS"),
filenameStyle: getSetting("save", "filenameStyle"),
disableMetadata: getSetting("save", "disableMetadata"),
twitterGif: getSetting("save", "twitterGif"),
tiktokH265: getSetting("save", "tiktokH265"),
alwaysProxy: getSetting("privacy", "alwaysProxy"),
}
await getServerInfo(); await getServerInfo();
const getCachedInfo = get(cachedInfo); const getCachedInfo = get(cachedInfo);

View File

@ -0,0 +1,160 @@
import API from "$lib/api/api";
import settings from "$lib/state/settings";
import lazySettingGetter from "$lib/settings/lazy-get";
import { get } from "svelte/store";
import { t } from "$lib/i18n/translations";
import { downloadFile } from "$lib/download";
import { createDialog } from "$lib/state/dialogs";
import { downloadButtonState } from "$lib/state/omnibox";
import { createSavePipeline } from "$lib/queen-bee/queue";
import type { DialogInfo } from "$lib/types/dialog";
import type { CobaltSaveRequestBody } from "$lib/types/api";
const defaultErrorPopup: DialogInfo = {
id: "save-error",
type: "small",
meowbalt: "error",
};
export const savingHandler = async ({ url, request }: { url?: string, request?: CobaltSaveRequestBody }) => {
downloadButtonState.set("think");
const errorButtons = [
{
text: get(t)("button.gotit"),
main: true,
action: () => { },
},
];
const getSetting = lazySettingGetter(get(settings));
if (!request && !url) return;
const selectedRequest = request || {
// pointing typescript to the fact that
// url is either present or not used at all,
// aka in cases when request is present
url: url!,
alwaysProxy: getSetting("save", "alwaysProxy"),
localProcessing: getSetting("save", "localProcessing"),
downloadMode: getSetting("save", "downloadMode"),
filenameStyle: getSetting("save", "filenameStyle"),
disableMetadata: getSetting("save", "disableMetadata"),
audioBitrate: getSetting("save", "audioBitrate"),
audioFormat: getSetting("save", "audioFormat"),
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
youtubeDubLang: getSetting("save", "youtubeDubLang"),
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
videoQuality: getSetting("save", "videoQuality"),
youtubeHLS: getSetting("save", "youtubeHLS"),
convertGif: getSetting("save", "convertGif"),
allowH265: getSetting("save", "allowH265"),
}
const response = await API.request(selectedRequest);
if (!response) {
downloadButtonState.set("error");
return createDialog({
...defaultErrorPopup,
buttons: errorButtons,
bodyText: get(t)("error.api.unreachable"),
});
}
if (response.status === "error") {
downloadButtonState.set("error");
return createDialog({
...defaultErrorPopup,
buttons: errorButtons,
bodyText: get(t)(response.error.code, response?.error?.context),
});
}
if (response.status === "redirect") {
downloadButtonState.set("done");
return downloadFile({
url: response.url,
urlType: "redirect",
});
}
if (response.status === "tunnel") {
downloadButtonState.set("check");
const probeResult = await API.probeCobaltTunnel(response.url);
if (probeResult === 200) {
downloadButtonState.set("done");
return downloadFile({
url: response.url,
});
} else {
downloadButtonState.set("error");
return createDialog({
...defaultErrorPopup,
buttons: errorButtons,
bodyText: get(t)("error.tunnel.probe"),
});
}
}
if (response.status === "local-processing") {
// TODO: remove debug logging
console.log(response);
downloadButtonState.set("done");
return createSavePipeline(response, selectedRequest);
}
if (response.status === "picker") {
downloadButtonState.set("done");
const buttons = [
{
text: get(t)("button.done"),
main: true,
action: () => { },
},
];
if (response.audio) {
const pickerAudio = response.audio;
buttons.unshift({
text: get(t)("button.download.audio"),
main: false,
action: () => {
downloadFile({
url: pickerAudio,
});
},
});
}
return createDialog({
id: "download-picker",
type: "picker",
items: response.picker,
buttons,
});
}
downloadButtonState.set("error");
return createDialog({
...defaultErrorPopup,
buttons: errorButtons,
bodyText: get(t)("error.api.unknown_response"),
});
}

View File

@ -14,6 +14,9 @@ const device = {
android: false, android: false,
mobile: false, mobile: false,
}, },
browser: {
chrome: false,
},
prefers: { prefers: {
language: "en", language: "en",
reducedMotion: false, reducedMotion: false,
@ -22,6 +25,7 @@ const device = {
supports: { supports: {
share: false, share: false,
directDownload: false, directDownload: false,
haptics: false,
}, },
userAgent: "sveltekit server", userAgent: "sveltekit server",
} }
@ -32,6 +36,9 @@ if (browser) {
const iPhone = ua.includes("iphone os"); const iPhone = ua.includes("iphone os");
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0; const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
const iosVersion = Number(ua.match(/iphone os (\d+)_/)?.[1]);
const modernIOS = iPhone && iosVersion >= 18;
const iOS = iPhone || iPad; const iOS = iPhone || iPad;
const android = ua.includes("android") || ua.includes("diordna"); const android = ua.includes("android") || ua.includes("diordna");
@ -42,11 +49,16 @@ if (browser) {
}; };
device.is = { device.is = {
mobile: iOS || android,
android,
iPhone, iPhone,
iPad, iPad,
iOS, iOS,
android, };
mobile: iOS || android,
device.browser = {
chrome: ua.includes("chrome/"),
}; };
device.prefers = { device.prefers = {
@ -58,6 +70,10 @@ if (browser) {
device.supports = { device.supports = {
share: navigator.share !== undefined, share: navigator.share !== undefined,
directDownload: !(installed && iOS), directDownload: !(installed && iOS),
// not sure if vibrations feel the same on android,
// so they're enabled only on ios 18+ for now
haptics: modernIOS,
}; };
device.userAgent = navigator.userAgent; device.userAgent = navigator.userAgent;

View File

@ -14,6 +14,8 @@ const variables = {
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'), PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'), PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
DEFAULT_API: getEnv('DEFAULT_API'), DEFAULT_API: getEnv('DEFAULT_API'),
// temporary variable until webcodecs features are ready for testing
ENABLE_WEBCODECS: !!getEnv('ENABLE_WEBCODECS'),
} }
const contacts = { const contacts = {

43
web/src/lib/haptics.ts Normal file
View File

@ -0,0 +1,43 @@
import { get } from "svelte/store";
import { device } from "$lib/device";
import settings from "$lib/state/settings";
const canUseHaptics = () => {
return device.supports.haptics && !get(settings).accessibility.disableHaptics;
}
export const hapticSwitch = () => {
if (!canUseHaptics()) return;
try {
const label = document.createElement("label");
label.ariaHidden = "true";
label.style.display = "none";
const input = document.createElement("input");
input.type = "checkbox";
input.setAttribute("switch", "");
label.appendChild(input);
document.head.appendChild(label);
label.click();
document.head.removeChild(label);
} catch {
// ignore
}
}
export const hapticConfirm = () => {
if (!canUseHaptics()) return;
hapticSwitch();
setTimeout(() => hapticSwitch(), 120);
}
export const hapticError = () => {
if (!canUseHaptics()) return;
hapticSwitch();
setTimeout(() => hapticSwitch(), 120);
setTimeout(() => hapticSwitch(), 240);
}

View File

@ -1,8 +1,8 @@
import mime from "mime"; import { OPFSStorage } from "$lib/storage";
import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli"; import LibAV, { type LibAV as LibAVInstance } from "@imput/libav.js-remux-cli";
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, FileInfo, RenderParams } from "./types/libav";
import type { FfprobeData } from "fluent-ffmpeg"; import type { FfprobeData } from "fluent-ffmpeg";
import { browser } from "$app/environment"; import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav";
export default class LibAVWrapper { export default class LibAVWrapper {
libav: Promise<LibAVInstance> | null; libav: Promise<LibAVInstance> | null;
@ -11,14 +11,18 @@ export default class LibAVWrapper {
constructor(onProgress?: FFmpegProgressCallback) { constructor(onProgress?: FFmpegProgressCallback) {
this.libav = null; this.libav = null;
this.concurrency = Math.min(4, browser ? navigator.hardwareConcurrency : 0); this.concurrency = Math.min(4, navigator.hardwareConcurrency || 0);
this.onProgress = onProgress; this.onProgress = onProgress;
} }
init() { init(options?: LibAV.LibAVOpts) {
if (!options) options = {
yesthreads: true,
}
if (this.concurrency && !this.libav) { if (this.concurrency && !this.libav) {
this.libav = LibAV.LibAV({ this.libav = LibAV.LibAV({
yesthreads: true, ...options,
base: '/_libav' base: '/_libav'
}); });
} }
@ -35,6 +39,8 @@ export default class LibAVWrapper {
if (!this.libav) throw new Error("LibAV wasn't initialized"); if (!this.libav) throw new Error("LibAV wasn't initialized");
const libav = await this.libav; const libav = await this.libav;
console.log('yay loaded libav :3');
await libav.mkreadaheadfile('input', blob); await libav.mkreadaheadfile('input', blob);
try { try {
@ -57,60 +63,31 @@ export default class LibAVWrapper {
} }
} }
static getExtensionFromType(blob: Blob) { async render({ files, output, args }: RenderParams) {
const extensions = mime.getAllExtensions(blob.type);
const overrides = ['mp3', 'mov'];
if (!extensions)
return;
for (const override of overrides)
if (extensions?.has(override))
return override;
return [...extensions][0];
}
async render({ blob, output, args }: RenderParams) {
if (!this.libav) throw new Error("LibAV wasn't initialized"); if (!this.libav) throw new Error("LibAV wasn't initialized");
const libav = await this.libav; const libav = await this.libav;
const inputKind = blob.type.split("/")[0];
const inputExtension = LibAVWrapper.getExtensionFromType(blob);
if (inputKind !== "video" && inputKind !== "audio") return; if (!(output.format && output.type)) {
if (!inputExtension) return; throw new Error("output's format or type is missing");
const input: FileInfo = {
kind: inputKind,
extension: inputExtension,
} }
if (!output) output = input; const outputName = `output.${output.format}`;
const ffInputs = [];
output.type = mime.getType(output.extension);
if (!output.type) return;
const outputName = `output.${output.extension}`;
try { try {
await libav.mkreadaheadfile("input", blob); for (let i = 0; i < files.length; i++) {
const file = files[i].file;
await libav.mkreadaheadfile(`input${i}`, file);
ffInputs.push('-i', `input${i}`);
}
// https://github.com/Yahweasel/libav.js/blob/7d359f69/docs/IO.md#block-writer-devices
await libav.mkwriterdev(outputName); await libav.mkwriterdev(outputName);
await libav.mkwriterdev('progress.txt'); await libav.mkwriterdev('progress.txt');
const MB = 1024 * 1024; const storage = await OPFSStorage.init();
const chunks: Uint8Array[] = [];
const chunkSize = Math.min(512 * MB, blob.size);
// since we expect the output file to be roughly the same size libav.onwrite = async (name, pos, data) => {
// as the original, preallocate its size for the output
for (let toAllocate = blob.size; toAllocate > 0; toAllocate -= chunkSize) {
chunks.push(new Uint8Array(chunkSize));
}
let actualSize = 0;
libav.onwrite = (name, pos, data) => {
if (name === 'progress.txt') { if (name === 'progress.txt') {
try { try {
return this.#emitProgress(data); return this.#emitProgress(data);
@ -119,26 +96,7 @@ export default class LibAVWrapper {
} }
} else if (name !== outputName) return; } else if (name !== outputName) return;
const writeEnd = pos + data.length; await storage.write(data, pos);
if (writeEnd > chunkSize * chunks.length) {
chunks.push(new Uint8Array(chunkSize));
}
const chunkIndex = pos / chunkSize | 0;
const offset = pos - (chunkSize * chunkIndex);
if (offset + data.length > chunkSize) {
chunks[chunkIndex].set(
data.subarray(0, chunkSize - offset), offset
);
chunks[chunkIndex + 1].set(
data.subarray(chunkSize - offset), 0
);
} else {
chunks[chunkIndex].set(data, offset);
}
actualSize = Math.max(writeEnd, actualSize);
}; };
await libav.ffmpeg([ await libav.ffmpeg([
@ -146,40 +104,28 @@ export default class LibAVWrapper {
'-loglevel', 'error', '-loglevel', 'error',
'-progress', 'progress.txt', '-progress', 'progress.txt',
'-threads', this.concurrency.toString(), '-threads', this.concurrency.toString(),
'-i', 'input', ...ffInputs,
...args, ...args,
outputName outputName
]); ]);
// if we didn't need as much space as we allocated for some reason, const file = await storage.res();
// shrink the buffers so that we don't inflate the file with zeroes
const outputView: Uint8Array[] = [];
for (let i = 0; i < chunks.length; ++i) { if (file.size === 0) return;
outputView.push(
chunks[i].subarray(
0, Math.min(chunkSize, actualSize)
)
);
actualSize -= chunkSize; return {
if (actualSize <= 0) { file,
break; type: output.type,
}
} }
const renderBlob = new Blob(
outputView,
{ type: output.type }
);
if (renderBlob.size === 0) return;
return renderBlob;
} finally { } finally {
try { try {
await libav.unlink(outputName); await libav.unlink(outputName);
await libav.unlink('progress.txt'); await libav.unlink('progress.txt');
await libav.unlinkreadaheadfile("input");
await Promise.allSettled(
files.map((_, i) =>
libav.unlinkreadaheadfile(`input${i}`)
));
} catch { /* catch & ignore */ } } catch { /* catch & ignore */ }
} }
} }
@ -192,7 +138,7 @@ export default class LibAVWrapper {
const entries = Object.fromEntries( const entries = Object.fromEntries(
text.split('\n') text.split('\n')
.filter(a => a) .filter(a => a)
.map(a => a.split('=', )) .map(a => a.split('='))
); );
const status: FFmpegProgressStatus = (() => { const status: FFmpegProgressStatus = (() => {

View File

@ -0,0 +1,104 @@
import { addItem } from "$lib/state/queen-bee/queue";
import { openQueuePopover } from "$lib/state/queue-visibility";
import type { CobaltPipelineItem } from "$lib/types/workers";
import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api";
export const getMediaType = (type: string) => {
const kind = type.split('/')[0];
// can't use .includes() here for some reason
if (kind === "video" || kind === "audio" || kind === "image") {
return kind;
}
}
export const createRemuxPipeline = (file: File) => {
// chopped khia
const parentId = crypto.randomUUID();
const mediaType = getMediaType(file.type);
const pipeline: CobaltPipelineItem[] = [{
worker: "remux",
workerId: crypto.randomUUID(),
parentId,
workerArgs: {
files: [{
file,
type: file.type,
}],
ffargs: [
"-c", "copy",
"-map", "0"
],
output: {
type: file.type,
format: file.name.split(".").pop(),
},
},
}];
if (mediaType) {
addItem({
id: parentId,
state: "waiting",
pipeline,
filename: file.name,
mimeType: file.type,
mediaType,
});
openQueuePopover();
}
}
export const createSavePipeline = (info: CobaltLocalProcessingResponse, request: CobaltSaveRequestBody) => {
// TODO: proper error here
if (!(info.output?.filename && info.output?.type)) return;
const parentId = crypto.randomUUID();
const pipeline: CobaltPipelineItem[] = [];
// reverse is needed for audio (second item) to be downloaded first
const tunnels = info.tunnel.reverse();
for (const tunnel of tunnels) {
pipeline.push({
worker: "fetch",
workerId: crypto.randomUUID(),
parentId,
workerArgs: {
url: tunnel,
},
});
}
pipeline.push({
worker: "remux",
workerId: crypto.randomUUID(),
parentId,
workerArgs: {
ffargs: [
"-c:v", "copy",
"-c:a", "copy"
],
output: {
type: info.output.type,
format: info.output.filename.split(".").pop(),
},
},
});
addItem({
id: parentId,
state: "waiting",
pipeline,
canRetry: true,
originalRequest: request,
filename: info.output.filename,
mimeType: info.output.type,
mediaType: "video",
});
openQueuePopover();
}

View File

@ -0,0 +1,50 @@
import { get } from "svelte/store";
import { queue } from "$lib/state/queen-bee/queue";
import { runRemuxWorker } from "$lib/queen-bee/runners/remux";
import { runFetchWorker } from "$lib/queen-bee/runners/fetch";
import type { CobaltPipelineItem } from "$lib/types/workers";
import type { CobaltFileReference } from "$lib/types/storage";
export const killWorker = (worker: Worker, unsubscribe: () => void, interval?: NodeJS.Timeout) => {
unsubscribe();
worker.terminate();
if (interval) clearInterval(interval);
}
export const startWorker = async ({ worker, workerId, parentId, workerArgs }: CobaltPipelineItem) => {
let files: CobaltFileReference[] = [];
switch (worker) {
case "remux":
if (workerArgs?.files) {
files = workerArgs.files;
}
if (files?.length === 0) {
const parent = get(queue)[parentId];
if (parent.state === "running" && parent.pipelineResults) {
files = parent.pipelineResults;
}
}
if (files.length > 0 && workerArgs.ffargs && workerArgs.output) {
await runRemuxWorker(
workerId,
parentId,
files,
workerArgs.ffargs,
workerArgs.output,
true, // resetStartCounter
);
}
break;
case "fetch":
if (workerArgs?.url) {
await runFetchWorker(workerId, parentId, workerArgs.url)
}
break;
}
}

View File

@ -0,0 +1,51 @@
import FetchWorker from "$lib/workers/fetch?worker";
import { killWorker } from "$lib/queen-bee/run-worker";
import { updateWorkerProgress } from "$lib/state/queen-bee/current-tasks";
import { pipelineTaskDone, itemError, queue } from "$lib/state/queen-bee/queue";
import type { CobaltQueue } from "$lib/types/queue";
export const runFetchWorker = async (workerId: string, parentId: string, url: string) => {
const worker = new FetchWorker();
const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
if (!queue[parentId]) {
// TODO: remove logging
console.log("worker's parent is gone, so it killed itself");
killWorker(worker, unsubscribe);
}
});
worker.postMessage({
cobaltFetchWorker: {
url
}
});
worker.onmessage = (event) => {
const eventData = event.data.cobaltFetchWorker;
if (!eventData) return;
if (eventData.progress) {
updateWorkerProgress(workerId, {
percentage: eventData.progress,
size: eventData.size,
})
}
if (eventData.result) {
killWorker(worker, unsubscribe);
return pipelineTaskDone(
parentId,
workerId,
eventData.result,
);
}
if (eventData.error) {
killWorker(worker, unsubscribe);
return itemError(parentId, workerId, eventData.error);
}
}
}

View File

@ -0,0 +1,109 @@
import RemuxWorker from "$lib/workers/remux?worker";
import { killWorker } from "$lib/queen-bee/run-worker";
import { updateWorkerProgress } from "$lib/state/queen-bee/current-tasks";
import { pipelineTaskDone, itemError, queue } from "$lib/state/queen-bee/queue";
import type { FileInfo } from "$lib/types/libav";
import type { CobaltQueue } from "$lib/types/queue";
import type { CobaltFileReference } from "$lib/types/storage";
let startAttempts = 0;
export const runRemuxWorker = async (
workerId: string,
parentId: string,
files: CobaltFileReference[],
args: string[],
output: FileInfo,
resetStartCounter?: boolean
) => {
const worker = new RemuxWorker();
// sometimes chrome refuses to start libav wasm,
// so we check if it started, try 10 more times if not, and kill self if it still doesn't work
// TODO: fix the underlying issue because this is ridiculous
if (resetStartCounter) startAttempts = 0;
let bumpAttempts = 0;
const startCheck = setInterval(async () => {
bumpAttempts++;
if (bumpAttempts === 10) {
startAttempts++;
if (startAttempts <= 10) {
killWorker(worker, unsubscribe, startCheck);
console.error("worker didn't start after 5 seconds, so it was killed and started again");
return await runRemuxWorker(workerId, parentId, files, args, output);
} else {
killWorker(worker, unsubscribe, startCheck);
console.error("worker didn't start after 10 attempts, so we're giving up");
// TODO: proper error code
return itemError(parentId, workerId, "worker didn't start");
}
}
}, 500);
const unsubscribe = queue.subscribe((queue: CobaltQueue) => {
if (!queue[parentId]) {
// TODO: remove logging
console.log("worker's parent is gone, so it killed itself");
killWorker(worker, unsubscribe, startCheck);
}
});
worker.postMessage({
cobaltRemuxWorker: {
files,
args,
output,
}
});
worker.onerror = (e) => {
console.error("remux worker exploded:", e);
killWorker(worker, unsubscribe, startCheck);
// TODO: proper error code
return itemError(parentId, workerId, "internal error");
};
let totalDuration: number | null = null;
worker.onmessage = (event) => {
const eventData = event.data.cobaltRemuxWorker;
if (!eventData) return;
clearInterval(startCheck);
// temporary debug logging
console.log(JSON.stringify(eventData, null, 2));
if (eventData.progress) {
if (eventData.progress.duration) {
totalDuration = eventData.progress.duration;
}
updateWorkerProgress(workerId, {
percentage: totalDuration ? (eventData.progress.durationProcessed / totalDuration) * 100 : 0,
size: eventData.progress.size,
})
}
if (eventData.render) {
killWorker(worker, unsubscribe, startCheck);
return pipelineTaskDone(
parentId,
workerId,
eventData.render,
);
}
if (eventData.error) {
killWorker(worker, unsubscribe, startCheck);
return itemError(parentId, workerId, eventData.error);
}
};
}

View File

@ -0,0 +1,68 @@
import { get } from "svelte/store";
import { startWorker } from "$lib/queen-bee/run-worker";
import { itemDone, itemError, itemRunning, queue } from "$lib/state/queen-bee/queue";
import { addWorkerToQueue, currentTasks } from "$lib/state/queen-bee/current-tasks";
import type { CobaltPipelineItem } from "$lib/types/workers";
const startPipeline = (pipelineItem: CobaltPipelineItem) => {
addWorkerToQueue(pipelineItem.workerId, {
type: pipelineItem.worker,
parentId: pipelineItem.parentId,
});
itemRunning(
pipelineItem.parentId,
pipelineItem.workerId,
);
startWorker(pipelineItem);
}
export const checkTasks = () => {
const queueItems = get(queue);
const ongoingTasks = get(currentTasks);
// TODO (?): task concurrency
if (Object.keys(ongoingTasks).length > 0) return;
for (const item of Object.keys(queueItems)) {
const task = queueItems[item];
if (task.state === "running") {
// if the running worker isn't completed and wait to be called again
// (on worker completion)
if (!task.completedWorkers?.includes(task.runningWorker)) {
break;
}
// if all workers are completed, then return the final file and go to next task
if (task.completedWorkers.length === task.pipeline.length) {
const finalFile = task.pipelineResults?.pop();
if (finalFile) {
itemDone(task.id, finalFile);
continue;
} else {
itemError(task.id, task.runningWorker, "no final file");
continue;
}
}
// if current worker is completed, but there are more workers,
// then start the next one and wait to be called again
for (let i = 0; i < task.pipeline.length; i++) {
if (!task.completedWorkers.includes(task.pipeline[i].workerId)) {
startPipeline(task.pipeline[i]);
break;
}
}
break;
}
// start the nearest waiting task and wait to be called again
if (task.state === "waiting" && task.pipeline.length > 0) {
startPipeline(task.pipeline[0]);
break;
}
}
}

View File

@ -2,34 +2,40 @@ import { defaultLocale } from "$lib/i18n/translations";
import type { CobaltSettings } from "$lib/types/settings"; import type { CobaltSettings } from "$lib/types/settings";
const defaultSettings: CobaltSettings = { const defaultSettings: CobaltSettings = {
schemaVersion: 4, schemaVersion: 5,
advanced: { advanced: {
debug: false, debug: false,
useWebCodecs: false,
}, },
appearance: { appearance: {
theme: "auto", theme: "auto",
language: defaultLocale, language: defaultLocale,
autoLanguage: true, autoLanguage: true,
},
accessibility: {
reduceMotion: false, reduceMotion: false,
reduceTransparency: false, reduceTransparency: false,
disableHaptics: false,
dontAutoOpenQueue: false,
}, },
save: { save: {
alwaysProxy: false,
localProcessing: false,
audioBitrate: "128", audioBitrate: "128",
audioFormat: "mp3", audioFormat: "mp3",
disableMetadata: false, disableMetadata: false,
downloadMode: "auto", downloadMode: "auto",
filenameStyle: "classic", filenameStyle: "classic",
savingMethod: "download", savingMethod: "download",
tiktokH265: false, allowH265: false,
tiktokFullAudio: false, tiktokFullAudio: false,
twitterGif: true, convertGif: true,
videoQuality: "1080", videoQuality: "1080",
youtubeVideoCodec: "h264", youtubeVideoCodec: "h264",
youtubeDubLang: "original", youtubeDubLang: "original",
youtubeHLS: false, youtubeHLS: false,
}, },
privacy: { privacy: {
alwaysProxy: false,
disableAnalytics: false, disableAnalytics: false,
}, },
processing: { processing: {

View File

@ -1,5 +1,5 @@
import defaults from "$lib/settings/defaults";
import type { CobaltSettings } from "$lib/types/settings"; import type { CobaltSettings } from "$lib/types/settings";
import defaults from "./defaults";
export default function lazySettingGetter(settings: CobaltSettings) { export default function lazySettingGetter(settings: CobaltSettings) {
// Returns the setting value only if it differs from the default. // Returns the setting value only if it differs from the default.

View File

@ -1,9 +1,10 @@
import type { RecursivePartial } from "$lib/types/generic"; import type { RecursivePartial } from "$lib/types/generic";
import type { import type {
PartialSettings,
AllPartialSettingsWithSchema, AllPartialSettingsWithSchema,
CobaltSettingsV3, CobaltSettingsV3,
CobaltSettingsV4, CobaltSettingsV4,
PartialSettings, CobaltSettingsV5,
} from "$lib/types/settings"; } from "$lib/types/settings";
import { getBrowserLanguage } from "$lib/settings/youtube-lang"; import { getBrowserLanguage } from "$lib/settings/youtube-lang";
@ -40,6 +41,42 @@ const migrations: Record<number, Migrator> = {
return out as AllPartialSettingsWithSchema; return out as AllPartialSettingsWithSchema;
}, },
[5]: (settings: AllPartialSettingsWithSchema) => {
const out = settings as RecursivePartial<CobaltSettingsV5>;
out.schemaVersion = 5;
if (settings?.save) {
if ("tiktokH265" in settings.save) {
out.save!.allowH265 = settings.save.tiktokH265;
delete settings.save.tiktokH265;
}
if ("twitterGif" in settings.save) {
out.save!.convertGif = settings.save.twitterGif;
delete settings.save.twitterGif;
}
}
if (settings?.privacy) {
if ("alwaysProxy" in settings.privacy) {
out.save!.alwaysProxy = settings.privacy.alwaysProxy;
delete settings.privacy.alwaysProxy;
}
}
if (settings?.appearance) {
if ("reduceMotion" in settings.appearance) {
out.accessibility!.reduceMotion = settings.appearance.reduceMotion;
delete settings.appearance.reduceMotion;
}
if ("reduceTransparency" in settings.appearance) {
out.accessibility!.reduceTransparency = settings.appearance.reduceTransparency;
delete settings.appearance.reduceTransparency;
}
}
return out as AllPartialSettingsWithSchema;
},
}; };
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => { export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {

View File

@ -1,3 +1,5 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { CobaltDownloadButtonState } from "$lib/types/omnibox";
export const link = writable(""); export const link = writable("");
export const downloadButtonState = writable<CobaltDownloadButtonState>("idle");

View File

@ -0,0 +1,40 @@
import { readable, type Updater } from "svelte/store";
import type { CobaltWorkerProgress } from "$lib/types/workers";
import type { CobaltCurrentTasks, CobaltCurrentTaskItem } from "$lib/types/queen-bee";
let update: (_: Updater<CobaltCurrentTasks>) => void;
const currentTasks = readable<CobaltCurrentTasks>(
{},
(_, _update) => { update = _update }
);
export function addWorkerToQueue(workerId: string, item: CobaltCurrentTaskItem) {
update(tasks => {
tasks[workerId] = item;
return tasks;
});
}
export function removeWorkerFromQueue(id: string) {
update(tasks => {
delete tasks[id];
return tasks;
});
}
export function updateWorkerProgress(workerId: string, progress: CobaltWorkerProgress) {
update(allTasks => {
allTasks[workerId].progress = progress;
return allTasks;
});
}
export function clearCurrentTasks() {
update(() => {
return {};
});
}
export { currentTasks };

View File

@ -0,0 +1,128 @@
import { readable, type Updater } from "svelte/store";
import { checkTasks } from "$lib/queen-bee/scheduler";
import { clearFileStorage, removeFromFileStorage } from "$lib/storage";
import { clearCurrentTasks, removeWorkerFromQueue } from "$lib/state/queen-bee/current-tasks";
import type { CobaltFileReference } from "$lib/types/storage";
import type { CobaltQueue, CobaltQueueItem } from "$lib/types/queue";
const clearPipelineCache = (queueItem: CobaltQueueItem) => {
if (queueItem.state === "running" && queueItem.pipelineResults) {
for (const item of queueItem.pipelineResults) {
removeFromFileStorage(item.file.name);
}
delete queueItem.pipelineResults;
}
if (queueItem.state === "done") {
removeFromFileStorage(queueItem.resultFile.file.name);
}
return queueItem;
}
let update: (_: Updater<CobaltQueue>) => void;
const queue = readable<CobaltQueue>(
{},
(_, _update) => { update = _update }
);
export function addItem(item: CobaltQueueItem) {
update(queueData => {
queueData[item.id] = item;
return queueData;
});
checkTasks();
}
export function itemError(id: string, workerId: string, error: string) {
update(queueData => {
if (queueData[id]) {
queueData[id] = clearPipelineCache(queueData[id]);
queueData[id] = {
...queueData[id],
state: "error",
errorCode: error,
}
}
return queueData;
});
removeWorkerFromQueue(workerId);
checkTasks();
}
export function itemDone(id: string, file: CobaltFileReference) {
update(queueData => {
if (queueData[id]) {
queueData[id] = clearPipelineCache(queueData[id]);
queueData[id] = {
...queueData[id],
state: "done",
resultFile: file,
}
}
return queueData;
});
checkTasks();
}
export function pipelineTaskDone(id: string, workerId: string, file: CobaltFileReference) {
update(queueData => {
if (queueData[id] && queueData[id].state === "running") {
queueData[id].pipelineResults = [...queueData[id].pipelineResults || [], file];
queueData[id].completedWorkers = [...queueData[id].completedWorkers || [], workerId];
}
return queueData;
});
removeWorkerFromQueue(workerId);
checkTasks();
}
export function itemRunning(id: string, workerId: string) {
update(queueData => {
if (queueData[id]) {
queueData[id] = {
...queueData[id],
state: "running",
runningWorker: workerId,
}
}
return queueData;
});
checkTasks();
}
export function removeItem(id: string) {
update(queueData => {
if (queueData[id].pipeline) {
for (const worker in queueData[id].pipeline) {
removeWorkerFromQueue(queueData[id].pipeline[worker].workerId);
}
clearPipelineCache(queueData[id]);
}
delete queueData[id];
return queueData;
});
checkTasks();
}
export function clearQueue() {
update(() => {
return {};
});
clearCurrentTasks();
clearFileStorage();
}
export { queue };

View File

@ -0,0 +1,11 @@
import settings from "$lib/state/settings";
import { get, writable } from "svelte/store";
export const queueVisible = writable(false);
export const openQueuePopover = () => {
const visible = get(queueVisible);
if (!visible && !get(settings).accessibility.dontAutoOpenQueue) {
return queueVisible.update(v => !v);
}
}

81
web/src/lib/storage.ts Normal file
View File

@ -0,0 +1,81 @@
const cobaltProcessingDir = "cobalt-processing-data";
export class OPFSStorage {
#root;
#handle;
#io;
constructor(root: FileSystemDirectoryHandle, handle: FileSystemFileHandle, reader: FileSystemSyncAccessHandle) {
this.#root = root;
this.#handle = handle;
this.#io = reader;
}
static async init() {
const root = await navigator.storage.getDirectory();
const cobaltDir = await root.getDirectoryHandle(cobaltProcessingDir, { create: true });
const handle = await cobaltDir.getFileHandle(crypto.randomUUID(), { create: true });
const reader = await handle.createSyncAccessHandle();
return new this(cobaltDir, handle, reader);
}
async res() {
// await for compat with ios 15
await this.#io.flush();
await this.#io.close();
return await this.#handle.getFile();
}
read(size: number, offset: number) {
const out = new Uint8Array(size);
const bytesRead = this.#io.read(out, { at: offset });
return out.subarray(0, bytesRead);
}
async write(data: Uint8Array | Int8Array, offset: number) {
return this.#io.write(data, { at: offset })
}
async destroy() {
await this.#root.removeEntry(this.#handle.name);
}
static isAvailable() {
return !!navigator.storage?.getDirectory;
}
}
export const removeFromFileStorage = async (filename: string) => {
const root = await navigator.storage.getDirectory();
const cobaltDir = await root.getDirectoryHandle(cobaltProcessingDir);
return await cobaltDir.removeEntry(filename);
}
export const clearFileStorage = async () => {
if (navigator.storage.getDirectory) {
const root = await navigator.storage.getDirectory();
try {
await root.removeEntry(cobaltProcessingDir, { recursive: true });
} catch {
// ignore the error because the dir might be missing and that's okay!
}
}
}
export const clearCacheStorage = async () => {
const keys = await caches.keys();
for (const key of keys) {
caches.delete(key);
}
}
export const getStorageQuota = async () => {
let estimate;
if (navigator.storage.estimate) {
estimate = await navigator.storage.estimate();
}
return estimate;
}

View File

@ -1,8 +1,11 @@
import type { CobaltSettings } from "$lib/types/settings";
enum CobaltResponseType { enum CobaltResponseType {
Error = 'error', Error = 'error',
Picker = 'picker', Picker = 'picker',
Redirect = 'redirect', Redirect = 'redirect',
Tunnel = 'tunnel', Tunnel = 'tunnel',
LocalProcessing = 'local-processing',
} }
export type CobaltErrorResponse = { export type CobaltErrorResponse = {
@ -40,6 +43,36 @@ type CobaltTunnelResponse = {
status: CobaltResponseType.Tunnel, status: CobaltResponseType.Tunnel,
} & CobaltPartialURLResponse; } & CobaltPartialURLResponse;
export type CobaltLocalProcessingResponse = {
status: CobaltResponseType.LocalProcessing,
// TODO: proper type for processing types
type: string,
service: string,
tunnel: string[],
output: {
type: string, // mimetype
filename: string,
metadata?: {
album?: string,
copyright?: string,
title?: string,
artist?: string,
track?: string,
date?: string
},
},
audio?: {
copy: boolean,
format: string,
bitrate: string,
},
isHLS?: boolean,
}
export type CobaltFileUrlType = "redirect" | "tunnel"; export type CobaltFileUrlType = "redirect" | "tunnel";
export type CobaltSession = { export type CobaltSession = {
@ -63,10 +96,17 @@ export type CobaltServerInfo = {
} }
} }
// TODO: strict partial
// this allows for extra properties, which is not ideal,
// but i couldn't figure out how to make a strict partial :(
export type CobaltSaveRequestBody =
{ url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse; export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse; export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
export type CobaltAPIResponse = CobaltErrorResponse export type CobaltAPIResponse = CobaltErrorResponse
| CobaltPickerResponse | CobaltPickerResponse
| CobaltRedirectResponse | CobaltRedirectResponse
| CobaltTunnelResponse; | CobaltTunnelResponse
| CobaltLocalProcessingResponse;

View File

@ -1,18 +1,16 @@
export type InputFileKind = "video" | "audio"; import type { CobaltFileReference } from "$lib/types/storage";
export type FileInfo = { export type FileInfo = {
type?: string | null, type?: string,
kind: InputFileKind, format?: string,
extension: string,
} }
export type RenderParams = { export type RenderParams = {
blob: Blob, files: CobaltFileReference[],
output?: FileInfo, output: FileInfo,
args: string[], args: string[],
} }
export type FFmpegProgressStatus = "continue" | "end" | "unknown"; export type FFmpegProgressStatus = "continue" | "end" | "unknown";
export type FFmpegProgressEvent = { export type FFmpegProgressEvent = {
status: FFmpegProgressStatus, status: FFmpegProgressStatus,

Some files were not shown because too many files have changed in this diff Show More