├── LICENSE ├── README.md ├── howto.png ├── index.js ├── manifest.json ├── mux.js ├── twitch-dvr.png └── twitch-dvr@48.png /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Karl Jiang 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitch-DVR player 2 | 3 | `twitch-dvr` is a Chrome extension that replaces the built-in Twitch player with a custom player that supports DVR: that is, you can seek to any point in the current broadcast and jump back to live at any time. Note that `twitch-dvr` does not replace the VOD player, nor the frontpage player. The user can switch between the DVR player and the default Twitch player at any time. 4 | 5 | `twitch-dvr` is written in vanilla Javascript 😱. 6 | 7 | ## Installation (for development) 8 | 9 | To install `twitch-dvr` for development, simply go to `chrome://extensions` (or `edge://extensions` on Edge), enable Developer Mode if you haven't already, and then choose "Load Unpacked" and navigate to and select `manifest.json` from this repo. 10 | 11 | ![how-to-install](howto.png) 12 | 13 | ## Notable differences from default Twitch player 14 | 15 | 1. `twitch-dvr` does not do *any form* of adaptive bitrate switching. It is the author's opinion that adaptive bitrate is a bad user experience, unless you have tons of variants like Netflix, and even sometimes then. It is understandable that Twitch switches variants when the tab loses focus to save on bandwidth, but this extension does not implement that feature either (that Twitch "feature" is also somewhat buggy, a lot of the times the player will forget your preferred quality). 16 | 2. Closed captioning is not supported. This would be nice to support at some point but so few streams have closed captions anyway that it's hard to find a stream to test this on. 17 | 3. Extensions are not supported. This is the biggest omission in the author's opinion but supporting this would be fairly difficult. It'd be great to get this to work in the future but I'm honestly not sure how realistic it is. 18 | 4. The latency of the DVR player is currently slightly worse than the default player on low-latency mode. This should be fairly easy to correct. As a side note from my testing, Twitch's latency optimizations on the server end are pretty impressive. 19 | 5. Playback speed is not supported. The author does not personally have a use case for this but it shouldn't be hard to implement this. 20 | 6. The UI is of course not as polished as the real Twitch player. The controls use a very naive heuristic that they pop up when your cursor is in the bottom third of the player, and a similar heuristic is used for the "Switch Player" button at the top. 21 | 22 | ## MP4 Muxing 23 | 24 | This extension was initially written to use Twitch's .ts segments directly with Media Source Extensions, however only Edge on Windows supports this. As a result, we now use [mux.js](https://github.com/videojs/mux.js/) to transmux .ts video to `mp4`. It would be nice to support pure `mp2t` mode for Edge, but unfortunately the fact that `mp4` has timestamps that always start at 0 vs `ts` having timestamps of actual stream time means that supporting both is a bit clunky. -------------------------------------------------------------------------------- /howto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caeleel/twitch-dvr/d5694daa2c98b9510a830c3431806901b2087d0e/howto.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | function addStyle(styleString) { 2 | const style = document.createElement('style'); 3 | style.textContent = styleString; 4 | document.head.append(style); 5 | } 6 | 7 | addStyle(` 8 | #player-container { 9 | width: 100%; 10 | height: 100%; 11 | position: absolute; 12 | top: 0; 13 | background-color: black; 14 | user-select: none; 15 | } 16 | 17 | #player-container.twitch { 18 | display: none; 19 | } 20 | 21 | #settings { 22 | display: none; 23 | position: absolute; 24 | line-height: 10px; 25 | font-size: 10px; 26 | color: white; 27 | top: 20px; 28 | right: 20px; 29 | flex-direction: column; 30 | align-items: flex-end; 31 | z-index: 100; 32 | } 33 | 34 | #settings-button { 35 | cursor: pointer; 36 | height: 18px; 37 | padding: 4px 10px; 38 | border-radius: 9px; 39 | background-color: rgba(0, 0, 0, 0.8); 40 | width: 60px; 41 | } 42 | 43 | #settings-menu { 44 | display: none; 45 | background-color: rgba(0, 0, 0, 0.8); 46 | padding: 4px 0; 47 | margin-top: 4px; 48 | border-radius: 2px; 49 | } 50 | 51 | .settings-item { 52 | padding: 4px 8px; 53 | cursor: pointer; 54 | } 55 | 56 | #settings.active #settings-menu { 57 | display: block; 58 | } 59 | 60 | #control-hover { 61 | position: absolute; 62 | height: 40%; 63 | width: 100%; 64 | bottom: 0; 65 | } 66 | 67 | #control-hover:hover #controls { 68 | display: block; 69 | } 70 | 71 | .overlay { 72 | display: none; 73 | position: absolute; 74 | top: 0; 75 | cursor: pointer; 76 | width: 100%; 77 | height: 100%; 78 | justify-content: center; 79 | align-items: center; 80 | background-color: rgba(0, 0, 0, 0.6); 81 | color: white; 82 | flex-direction: column; 83 | font-weight: 600; 84 | font-size: 20px; 85 | } 86 | 87 | .overlay div { 88 | text-align: center; 89 | width: 600px; 90 | margin-bottom: 20px; 91 | } 92 | 93 | .overlay a, .overlay a:hover { 94 | color: #C5B6E2; 95 | } 96 | 97 | .overlay button { 98 | border: 1px solid white; 99 | padding: 2px 10px; 100 | border-radius: 4px; 101 | } 102 | 103 | #toast-overlay { 104 | display: none; 105 | position: absolute; 106 | bottom: 100px; 107 | width: 100%; 108 | justify-content: center; 109 | pointer-events: none; 110 | } 111 | 112 | #toast { 113 | border-radius: 5px; 114 | background-color: rgba(0, 0, 0, 0.8); 115 | color: white; 116 | font-weight: 600; 117 | font-size: 20px; 118 | padding: 10px 20px; 119 | border: 2px solid white; 120 | } 121 | 122 | video { 123 | position: absolute; 124 | } 125 | 126 | #fullscreen, #play, #pause, #volume-container, #seek-outer { 127 | bottom: 20px; 128 | height: 20px; 129 | } 130 | 131 | .control { 132 | position: absolute; 133 | cursor: pointer; 134 | } 135 | 136 | #live { 137 | height: 8px; 138 | width: 8px; 139 | border-radius: 4px; 140 | bottom: 26px; 141 | right: 172px; 142 | } 143 | 144 | #live.live, #live.vod:hover { 145 | background-color: red; 146 | box-shadow: 0 0 4px red; 147 | } 148 | 149 | #live.vod { 150 | background-color: #555; 151 | box-shadow: 0 0 4px #555; 152 | } 153 | 154 | #live.vod:hover #go-live { 155 | display: flex; 156 | left: -26px; 157 | bottom: 14px; 158 | } 159 | 160 | #controls { 161 | display: none; 162 | position: absolute; 163 | bottom: 0; 164 | height: 60px; 165 | width: 100%; 166 | background-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)); 167 | font-size: 10px; 168 | line-height: 10px; 169 | font-weight: 600; 170 | font-family: sans-serif; 171 | } 172 | 173 | #volume { 174 | bottom: 21px; 175 | } 176 | 177 | #volume-container { 178 | width: 115px; 179 | overflow: hidden; 180 | left: 80px; 181 | } 182 | 183 | #seek-outer { 184 | width: calc(100% - 480px); 185 | left: 230px; 186 | } 187 | 188 | #seek-container { 189 | overflow: hidden; 190 | width: 100%; 191 | height: 20px; 192 | } 193 | 194 | #seek-outer:hover #seek-tooltip { 195 | display: flex; 196 | } 197 | 198 | .tooltip { 199 | display: none; 200 | width: 60px; 201 | flex-direction: column; 202 | align-items: center; 203 | left: -30px; 204 | bottom: 18px; 205 | height: 26px; 206 | } 207 | 208 | .tooltip-text { 209 | width: 100%; 210 | border-radius: 4px; 211 | height: 18px; 212 | box-sizing: border-box; 213 | padding: 4px 6px; 214 | text-align: center; 215 | color: black; 216 | } 217 | 218 | #tooltip-text { 219 | background-color: white; 220 | } 221 | 222 | .triangle { 223 | width: 0; 224 | height: 0; 225 | border-left: 5px solid transparent; 226 | border-right: 5px solid transparent; 227 | } 228 | 229 | #tooltip-triangle { 230 | border-top: 10px solid white; 231 | } 232 | 233 | #go-live-text { 234 | background-color: red; 235 | color: white; 236 | } 237 | 238 | #go-live-triangle { 239 | border-top: 10px solid red; 240 | } 241 | 242 | .slider-empty, .slider, .slider-filled, .slider-handle { 243 | pointer-events: none; 244 | } 245 | 246 | .slider-empty { 247 | height: 2px; 248 | border: 1px solid rgba(0, 0, 0, .2); 249 | background-color: rgba(255, 255, 255, .2); 250 | width: 100%; 251 | left: 0; 252 | top: 8px; 253 | } 254 | 255 | .slider-filled { 256 | top: 9px; 257 | height: 2px; 258 | width: calc(100% - 4px); 259 | background-color: white; 260 | } 261 | 262 | .slider-handle { 263 | background-color: white; 264 | border-radius: 7px; 265 | border: 0.5px solid rgba(0, 0, 0, .2); 266 | right: 0.5px; 267 | top: 2.5px; 268 | width: 14px; 269 | height: 14px; 270 | } 271 | 272 | #fullscreen { 273 | right: 20px; 274 | } 275 | 276 | #play, #pause { 277 | left: 20px; 278 | } 279 | 280 | #play { 281 | display: none; 282 | } 283 | 284 | #volume { 285 | left: 60px; 286 | } 287 | 288 | #clip { 289 | display: none; 290 | bottom: 20px; 291 | height: 20px; 292 | width: 20px; 293 | right: 50px; 294 | } 295 | 296 | #theater { 297 | display: flex; 298 | bottom: 22px; 299 | height: 16px; 300 | width: 18px; 301 | right: 81px; 302 | } 303 | 304 | #theater.inactive { 305 | border: 2px solid white; 306 | border-radius: 2px; 307 | box-sizing: border-box; 308 | } 309 | 310 | #theater.active #theaterl { 311 | border-radius: 2px 0 0 2px; 312 | height: 16px; 313 | width: 10px; 314 | } 315 | 316 | #theater.active #theaterr { 317 | width: 6px; 318 | height: 16px; 319 | border-radius: 0 2px 2px 0; 320 | margin-left: 2px; 321 | } 322 | 323 | #theater.active #theaterl, #theater.active #theaterr { 324 | background-color: white; 325 | } 326 | 327 | #theater.inactive #theaterl { 328 | border-right: 2px solid white; 329 | width: 10px; 330 | height: 12px; 331 | } 332 | 333 | #quality { 334 | bottom: 20px; 335 | height: 20px; 336 | right: 110px; 337 | box-sizing: border-box; 338 | padding: 4px 6px; 339 | background-color: rgba(0, 0, 0, 0.8); 340 | color: white; 341 | border-radius: 9px; 342 | } 343 | 344 | #timer { 345 | bottom: 19.5px; 346 | height: 20px; 347 | left: calc(100% - 240px); 348 | box-sizing: border-box; 349 | padding: 4px 6px; 350 | color: white; 351 | } 352 | 353 | #quality-picker { 354 | display: none; 355 | bottom: 42px; 356 | right: 110px; 357 | background-color: rgba(0, 0, 0, 0.8); 358 | color: white; 359 | padding: 6px 0; 360 | border-radius: 2px; 361 | } 362 | 363 | .picker { 364 | text-align: right; 365 | padding: 4px 7.5px; 366 | } 367 | 368 | .picker:hover, .settings-item:hover { 369 | background-color: rgba(255, 255, 255, .2); 370 | } 371 | `); 372 | 373 | 374 | let mediaSrc = new MediaSource(); 375 | let sourceBuffer = null; 376 | let arrayOfBlobs = []; 377 | let player = null; 378 | let volume = null; 379 | let seekTooltip = null; 380 | let seekTooltipText = null; 381 | let seekContainer = null; 382 | let seekSlider = null; 383 | let updateSeekLabel = null; 384 | let isTransitioningTypes = false; 385 | let generation = 0; 386 | let vodOffset = 0; 387 | let videoMode = 'live'; 388 | let transmuxer = null; 389 | let inChannelPage = false; 390 | let playerInstalled = false; 391 | let seeking = false; 392 | let channel = null; 393 | let vodId = null; 394 | let adjustedTime = null; 395 | let vodsDisabled = false; 396 | let vodsSubOnly = false; 397 | let playerType = localStorage.getItem('twitch-dvr:player-type') ? localStorage.getItem('twitch-dvr:player-type') : 'dvr'; 398 | 399 | const bufferLimit = 200; 400 | const handleRadius = 7.25; 401 | const vodDeadzone = 15; 402 | const vodDeadzoneBuffer = 15; 403 | let vodSegmentLen = 10; 404 | 405 | const clientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; 406 | 407 | function hideToggle() { 408 | if (document.getElementById('settings')) { 409 | document.getElementById('settings').style.display = ''; 410 | } 411 | 412 | if (paused) return; 413 | if (document.getElementById('player-container')) { 414 | document.getElementById('player-container').style.cursor = 'none'; 415 | document.getElementById('controls').style.display = 'none'; 416 | } 417 | } 418 | 419 | let toggleTimer = null; 420 | 421 | function showToggle() { 422 | if (toggleTimer) clearTimeout(toggleTimer); 423 | toggleTimer = null; 424 | if (document.getElementById('settings')) { 425 | document.getElementById('settings').style.display = 'flex'; 426 | } 427 | 428 | if (document.getElementById('player-container')) { 429 | document.getElementById('player-container').style.cursor = ''; 430 | document.getElementById('controls').style.display = 'flex'; 431 | } 432 | } 433 | 434 | function showToggleForAWhile() { 435 | if (!inChannelPage) return; 436 | showToggle(); 437 | toggleTimer = setTimeout(hideToggle, 3000); 438 | } 439 | 440 | function toggleFullscreen() { 441 | if (document.fullscreenElement) { 442 | if (document.exitFullscreen) { 443 | document.exitFullscreen(); 444 | } 445 | } else { 446 | const element = document.getElementById('player-container'); 447 | if (element.requestFullscreen) { 448 | element.requestFullscreen(); 449 | } 450 | } 451 | } 452 | 453 | function toggleTheaterModeIcon() { 454 | const button = document.getElementById('theater'); 455 | button.classList.toggle('active'); 456 | button.classList.toggle('inactive'); 457 | } 458 | 459 | function toggleTheaterMode() { 460 | toggleTheaterModeIcon(); 461 | document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 84, altKey: true })); 462 | } 463 | 464 | let toastTimer = null; 465 | function showToast(text) { 466 | if (toastTimer) { 467 | clearTimeout(toastTimer); 468 | } 469 | document.getElementById('toast').innerText = text; 470 | document.getElementById('toast-overlay').style.display = 'flex'; 471 | toastTimer = setTimeout(() => { 472 | document.getElementById('toast-overlay').style.display = 'none'; 473 | toastTimer = null; 474 | }, 5000); 475 | } 476 | 477 | function getOauthToken() { 478 | const cookies = document.cookie.split('; '); 479 | for (const cookie of cookies) { 480 | const [k, v] = cookie.split('='); 481 | if (k === 'twilight-user') { 482 | const twilightUser = JSON.parse(decodeURIComponent(v)); 483 | return twilightUser.authToken; 484 | } 485 | } 486 | return null; 487 | } 488 | 489 | async function getM3U8(isLive, key, clientId) { 490 | const json = await fetchGQL([{ 491 | operationName: 'PlaybackAccessToken_Template', 492 | query: 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}', 493 | variables: { 494 | isLive, 495 | isVod: !isLive, 496 | login: isLive ? key : '', 497 | playerType: 'site', 498 | vodID: isLive ? '' : key, 499 | }, 500 | }]); 501 | const rawToken = isLive ? json[0].data.streamPlaybackAccessToken : json[0].data.videoPlaybackAccessToken; 502 | const token = rawToken.value; 503 | const sig = rawToken.signature; 504 | 505 | let url = `api/channel/hls/${key}`; 506 | if (!isLive) url = `vod/${key}`; 507 | const resp = await fetch(`https://usher.ttvnw.net/${url}.m3u8?allow_source=true&allow_audio_only=true&fast_bread=true&playlist_include_framerate=true&reassignments_supported=true&sig=${sig}&token=${encodeURIComponent(token)}`); 508 | if (resp.status === 403) { 509 | vodsSubOnly = true; 510 | vodsDisabled = true; 511 | throw new Error('Auth error'); 512 | } 513 | 514 | if (resp.status !== 200) { 515 | throw new Error('Stream not live'); 516 | } 517 | const text = await resp.text(); 518 | 519 | const parsed = parseMasterManifest(text); 520 | return parsed; 521 | } 522 | 523 | function getLiveM3U8(channel, clientId) { 524 | return getM3U8(true, channel, clientId); 525 | } 526 | 527 | function showErrorOverlay(msg = 'Could not rewind the stream. This could be because VODs are disabled or not being auto-published. See this page for more info.') { 528 | document.getElementById('error-overlay').style.display = 'flex'; 529 | document.getElementById('error-text').innerHTML = msg; 530 | } 531 | 532 | async function getVODUrl(channel, clientId) { 533 | const oauthToken = getOauthToken(); 534 | let json = await fetchGQL([{ 535 | operationName: 'HomeOfflineCarousel', 536 | variables: { 537 | channelLogin: channel, 538 | includeTrailerUpsell: false, 539 | trailerUpsellVideoID: '' 540 | }, 541 | extensions: { 542 | persistedQuery: { 543 | version: 1, 544 | sha256Hash: '0c97fdcb4e0366b25ae35eb89cc932ecbbb056f663f92735d53776602e4e94c5', 545 | } 546 | }, 547 | }]); 548 | 549 | await totalElapsedPromise; 550 | const video = json[0].data.user.archiveVideos.edges[0]; 551 | if (!video) { 552 | vodsDisabled = true; 553 | return; 554 | } 555 | const vodAgo = (new Date() - new Date(video.node.publishedAt)) / 1000; 556 | const timeDiff = Math.abs(vodAgo - totalElapsed); 557 | vodId = video.node.id; 558 | if (timeDiff > 1200) { 559 | vodsDisabled = true; 560 | return; 561 | } 562 | const manifests = await getM3U8(false, vodId, clientId); 563 | 564 | const resp = await fetch(manifests[0].url); 565 | const manifest = await resp.text(); 566 | const histogram = {}; 567 | let maxCount = 0; 568 | for (const line of manifest.split('\n')) { 569 | if (line.substring(0, 8) === '#EXTINF:') { 570 | const dur = parseFloat(line.substring(8).split(',')[0]); 571 | if (histogram[dur]) { 572 | histogram[dur]++; 573 | } else { 574 | histogram[dur] = 1; 575 | } 576 | if (histogram[dur] > maxCount) { 577 | maxCount++; 578 | vodSegmentLen = dur; 579 | } 580 | } 581 | } 582 | 583 | return manifests; 584 | } 585 | 586 | async function bufferVOD(url, time, first) { 587 | const startGeneration = generation; 588 | 589 | const idx = Math.floor(time / vodSegmentLen); 590 | const baseURL = vodURLs[variantIdx]; 591 | vodOffset = time % vodSegmentLen; 592 | 593 | if (!sourceBuffer.buffered.length || sourceBuffer.buffered.end(0) - player.currentTime < 0) { 594 | first = true; 595 | } 596 | const bufferAmount = Math.max(0, Math.min(bufferLimit / 2, maxTime - time - vodDeadzone)); 597 | const toBuffer = first ? Math.floor(bufferAmount / vodSegmentLen) : Math.max(0, Math.floor((bufferAmount - sourceBuffer.buffered.end(0) + player.currentTime) / vodSegmentLen)); 598 | 599 | if (toBuffer > 0) { 600 | time += vodSegmentLen; 601 | await downloadSegments(startGeneration, Promise.resolve(), [{ url: `${baseURL}${idx}.ts`, type: 'vod' }]); 602 | } 603 | 604 | if (generation !== startGeneration) return; 605 | const waitTime = toBuffer > 1 ? 100 : 2000; 606 | vodTimer = setTimeout(() => bufferVOD(url, time, false), waitTime); 607 | } 608 | 609 | let lastFetched = new Set(); 610 | let paused = true; 611 | let firstTime = true; 612 | let totalElapsed = 0; 613 | let vodOrigin = 0; 614 | let tmpVodOrigin = null; 615 | let totalElapsedIsSet = function () { }; 616 | let totalElapsedPromise = new Promise(resolve => { totalElapsedIsSet = resolve }); 617 | 618 | function resetTotalElapsedPromise() { 619 | vodsDisabled = false; 620 | vodsSubOnly = false; 621 | totalElapsedPromise = new Promise(resolve => { totalElapsedIsSet = resolve }); 622 | } 623 | 624 | async function bufferLive(url) { 625 | const resp = await fetch(url); 626 | const m3u8 = await resp.text(); 627 | const segments = []; 628 | const fetched = new Set(); 629 | let canHaveDiscontinuity = false; 630 | 631 | const lines = m3u8.split('\n'); 632 | for (let i = 0; i < lines.length; i++) { 633 | const line = lines[i]; 634 | let segment = null; 635 | if (line.substring(0, 8) === '#EXTINF:') { 636 | canHaveDiscontinuity = true; 637 | const isLive = line.split(',')[1] === 'live'; 638 | segment = { url: lines[i + 1], type: isLive ? 'live' : 'ad' }; 639 | } else if (line.substring(0, 23) === '#EXT-X-TWITCH-PREFETCH:') { 640 | segment = { url: line.substring(23), type: 'live' }; 641 | } else if (line.substring(0, 25) === '#EXT-X-TWITCH-TOTAL-SECS:') { 642 | totalElapsed = parseFloat(line.substring(25)); 643 | totalElapsedIsSet(); 644 | } else if (line === "#EXT-X-DISCONTINUITY" && canHaveDiscontinuity) { 645 | const discontinuityID = lines[i - 1]; 646 | fetched.add(discontinuityID); 647 | if (lastFetched.has(discontinuityID)) continue; 648 | segment = { type: 'discontinuity' }; 649 | } 650 | 651 | if (segment) { 652 | fetched.add(segment.url); 653 | 654 | if (!lastFetched.has(segment.url)) { 655 | totalElapsed -= (budget / 1000); 656 | segments.push(segment); 657 | } 658 | } 659 | } 660 | 661 | const lastFetchedSize = lastFetched.size; 662 | lastFetched = fetched; 663 | 664 | if (segments.length > 3 && lastFetchedSize === 0) return segments.slice(segments.length - 3); 665 | 666 | return segments; 667 | } 668 | 669 | function parseMasterManifest(m3u8) { 670 | const lines = m3u8.split('\n'); 671 | const variants = []; 672 | for (let i = 0; i < lines.length; i++) { 673 | const line = lines[i]; 674 | if (line.substring(0, 18) === '#EXT-X-STREAM-INF:') { 675 | const parts = line.substring(18).split(','); 676 | const variant = {}; 677 | for (let j = 0; j < parts.length; j++) { 678 | const part = parts[j]; 679 | const vals = part.split('='); 680 | switch (vals[0]) { 681 | case 'BANDWIDTH': 682 | variant.bandwidth = parseInt(vals[1]); 683 | break; 684 | case 'RESOLUTION': 685 | variant.vHeight = vals[1].split('x')[1]; 686 | variant.resolution = `${variant.vHeight}p`; 687 | break; 688 | case 'CODECS': 689 | variant.codecs = `${vals[1]},${parts[j + 1]}`; 690 | break; 691 | case 'VIDEO': 692 | variant.name = vals[1]; 693 | if (vals[1] === '"audio_only"') { 694 | variant.vHeight = 0; 695 | variant.resolution = 'audio'; 696 | variant.framerate = 30; 697 | variant.codecs = '"mp4a.40.2"'; 698 | } 699 | break; 700 | case 'FRAME-RATE': 701 | variant.framerate = parseFloat(vals[1]); 702 | break; 703 | } 704 | } 705 | if (variant.framerate !== 30) { 706 | variant.resolution += Math.ceil(variant.framerate); 707 | } 708 | variant.url = lines[i + 1]; 709 | variants.push(variant); 710 | } 711 | } 712 | 713 | return variants.sort((a, b) => b.vHeight - a.vHeight); 714 | } 715 | 716 | async function appendToSourceBuffer() { 717 | if (!sourceBuffer) return; 718 | 719 | await Promise.resolve(); 720 | 721 | if (mediaSrc.readyState === 'open' && sourceBuffer && sourceBuffer.updating === false && arrayOfBlobs.length > 0) { 722 | const blob = arrayOfBlobs.shift(); 723 | sourceBuffer.appendBuffer(blob); 724 | } 725 | } 726 | 727 | let lastPlayerTime = -1; 728 | let lastRealTime = -1; 729 | 730 | function afterBufferUpdate() { 731 | if (!sourceBuffer) return; 732 | 733 | lastPlayerTime = player.currentTime; 734 | lastRealTime = Date.now(); 735 | const numBuffers = player.buffered.length; 736 | 737 | if (firstTime && numBuffers) { 738 | player.currentTime = sourceBuffer.buffered.start(0) + (videoMode === 'vod' ? vodOffset : 0); 739 | if (videoMode === 'live') { 740 | timeOriginPlayerTime = totalElapsed; 741 | timeOrigin = Date.now(); 742 | } 743 | if (!paused) { 744 | setTimeout(() => { 745 | player.play().catch((e) => { 746 | player.muted = true; 747 | document.getElementById("mute-overlay").style.display = "flex"; 748 | }); 749 | }, 0); 750 | } else if (videoMode === 'vod') { 751 | player.pause(); 752 | } 753 | tmpVodOrigin = null; 754 | firstTime = false; 755 | } else if (numBuffers && player.currentTime < sourceBuffer.buffered.start(0)) { 756 | player.currentTime = sourceBuffer.buffered.start(0); 757 | } 758 | 759 | if (sourceBuffer.updating === false && numBuffers && player.buffered.end(0) - player.buffered.start(0) > bufferLimit) { 760 | sourceBuffer.remove(player.buffered.start(0), player.buffered.end(0) - bufferLimit); 761 | } 762 | } 763 | 764 | let rebufferTimer = null; 765 | let vodTimer = null; 766 | let variantIdx = 0; 767 | const budget = 2000; 768 | let budgetEnd = 0; 769 | let variants = []; 770 | let vodVariants = []; 771 | let vodURLs = []; 772 | let maxTime = 1; 773 | let timeOrigin = 0; 774 | let timeOriginPlayerTime = 0; 775 | 776 | function clearTimers() { 777 | if (sourceBuffer) { 778 | try { 779 | sourceBuffer.abort(); 780 | let maxBuffered = 0; 781 | for (let i = 0; i < sourceBuffer.buffered.length; i++) { 782 | if (sourceBuffer.buffered.end(i) > maxBuffered) { 783 | maxBuffered = sourceBuffer.buffered.end(i); 784 | } 785 | } 786 | 787 | if (maxBuffered > 0) { 788 | sourceBuffer.remove(0, maxBuffered); 789 | } 790 | } catch (e) { 791 | // pass 792 | } 793 | } 794 | generation++; 795 | if (rebufferTimer) { 796 | clearTimeout(rebufferTimer); 797 | rebufferTimer = null; 798 | } 799 | if (vodTimer) { 800 | clearTimeout(vodTimer); 801 | vodTimer = null; 802 | } 803 | arrayOfBlobs = []; 804 | } 805 | 806 | function pause() { 807 | if (!document.getElementById('pause')) return; 808 | 809 | document.getElementById('pause').style.display = 'none'; 810 | document.getElementById('play').style.display = 'block'; 811 | 812 | if (videoMode === 'vod') { 813 | player.pause(); 814 | paused = true; 815 | return; 816 | } 817 | 818 | lastFetched = new Set(); 819 | clearTimers(); 820 | resetTransmuxer(); 821 | paused = true; 822 | document.getElementById('controls').style.display = 'block'; 823 | } 824 | 825 | function play() { 826 | document.getElementById('play').style.display = 'none'; 827 | document.getElementById('pause').style.display = 'block'; 828 | if (videoMode === 'vod') { 829 | player.play().catch((e) => { 830 | player.muted = true; 831 | document.getElementById("mute-overlay").style.display = "flex"; 832 | }); 833 | paused = false; 834 | return; 835 | } 836 | 837 | if (paused) { 838 | rebuffer(); 839 | paused = false; 840 | firstTime = true; 841 | } 842 | } 843 | 844 | function getSeekWidth() { 845 | const container = document.getElementById('player-container'); 846 | if (!container) return 0; 847 | return container.getBoundingClientRect().width - 480; 848 | } 849 | 850 | function getTimeAtOffset(offsetX) { 851 | const width = getSeekWidth(); 852 | if (offsetX < handleRadius) return 0; 853 | else if (width - offsetX < handleRadius) return maxTime; 854 | else return (offsetX - handleRadius) / (width - 2 * handleRadius) * maxTime; 855 | } 856 | 857 | function updateSeekBar(offsetX) { 858 | if (vodsDisabled) { 859 | return; 860 | } 861 | const seekTime = getTimeAtOffset(offsetX); 862 | const width = getSeekWidth(); 863 | seekSlider.style.width = `${(width - 2 * handleRadius) * seekTime / maxTime + 2 * handleRadius}px`; 864 | } 865 | 866 | function onSeekDown(ev) { 867 | const leftSide = ev.pageX - ev.offsetX; 868 | const leftSideForLabel = ev.pageX - ev.layerX; 869 | updateSeekBar(ev.offsetX); 870 | seeking = true; 871 | seekTooltip.style.display = 'flex'; 872 | 873 | function mouseMove(ev) { 874 | updateSeekBar(ev.pageX - leftSide); 875 | updateSeekLabel(ev.pageX - leftSideForLabel); 876 | } 877 | 878 | function mouseUp(ev) { 879 | seeking = false; 880 | document.removeEventListener('mousemove', mouseMove); 881 | document.removeEventListener('mouseup', mouseUp); 882 | seek(ev.pageX - leftSide); 883 | seekTooltip.style.display = ''; 884 | } 885 | 886 | document.addEventListener('mousemove', mouseMove); 887 | document.addEventListener('mouseup', mouseUp); 888 | } 889 | 890 | function seek(offsetX) { 891 | const seekTime = getTimeAtOffset(offsetX); 892 | seekToTime(seekTime); 893 | } 894 | 895 | function seekToTime(seekTime) { 896 | if (maxTime - seekTime < vodDeadzoneBuffer + vodDeadzoneBuffer) { 897 | golive(); 898 | return; 899 | } 900 | 901 | if (vodsSubOnly) { 902 | showErrorOverlay("VODs on this channel are sub-only. You must sub in order to rewind."); 903 | return; 904 | } 905 | 906 | if (vodsDisabled) { 907 | showErrorOverlay(); 908 | return; 909 | } 910 | 911 | const timer = document.getElementById('timer'); 912 | if (timer) timer.innerText = formatTime(seekTime); 913 | 914 | const buffered = sourceBuffer.buffered; 915 | const videoTime = seekTime - vodOrigin; 916 | if (videoMode === 'vod' && buffered.length && buffered.start(0) <= videoTime && buffered.end(0) >= videoTime) { 917 | player.currentTime = videoTime; 918 | return; 919 | } 920 | clearTimers(); 921 | 922 | switchMode('vod'); 923 | vodOrigin = seekTime - (seekTime % vodSegmentLen); 924 | tmpVodOrigin = seekTime; 925 | firstTime = true; 926 | resetTransmuxer(); 927 | vodTimer = setTimeout(() => bufferVOD(vodVariants[variantIdx], seekTime, true), 500); 928 | } 929 | 930 | function golive() { 931 | if (videoMode === 'live') return; 932 | 933 | clearTimers(); 934 | switchMode('live'); 935 | paused = true; 936 | play(); 937 | } 938 | 939 | function togglePicker() { 940 | const picker = document.getElementById('quality-picker'); 941 | const pickerOpen = picker.style.display === 'block'; 942 | if (pickerOpen) { 943 | document.removeEventListener('click', togglePicker); 944 | } else { 945 | setTimeout(() => document.addEventListener('click', togglePicker), 1); 946 | } 947 | picker.style.display = pickerOpen ? 'none' : 'block'; 948 | } 949 | 950 | function playNative(mute) { 951 | let muted = null; 952 | for (const video of document.querySelectorAll('.video-player__container video')) { 953 | if (video.id !== 'player') { 954 | muted = video.muted; 955 | if (mute) video.muted = true; 956 | video.play(); 957 | break; 958 | } 959 | } 960 | return muted; 961 | } 962 | 963 | function togglePlayer() { 964 | let typeName = 'Twitch'; 965 | if (playerType === 'dvr') { 966 | typeName = 'DVR'; 967 | playerType = 'twitch'; 968 | pause(); 969 | playNative(); 970 | } else { 971 | const inTheaterMode = !!document.querySelector('.video-player__container--theatre'); 972 | const theaterMode = document.getElementById('theater'); 973 | if (inTheaterMode !== theaterMode.classList.contains('active')) { 974 | toggleTheaterModeIcon(); 975 | } 976 | playerType = 'dvr'; 977 | switchChannel(); 978 | } 979 | 980 | localStorage.setItem('twitch-dvr:player-type', playerType); 981 | document.querySelector('#toggle').innerText = `Switch to ${typeName} player`; 982 | document.getElementById('player-container').className = playerType; 983 | } 984 | 985 | function toggleSettings() { 986 | const settings = document.querySelector('#settings'); 987 | const className = settings.className; 988 | 989 | if (className !== 'active') { 990 | settings.className = 'active'; 991 | setTimeout(() => document.addEventListener('click', toggleSettings), 1); 992 | } else { 993 | settings.className = ''; 994 | document.removeEventListener('click', toggleSettings); 995 | } 996 | } 997 | 998 | let bufferPromise = null; 999 | 1000 | async function downloadSegments(startGeneration, lastPromise, segments) { 1001 | let count = 0; 1002 | for (const segment of segments) { 1003 | if (startGeneration !== generation) break; 1004 | try { 1005 | if (segment.type === "discontinuity") { 1006 | isTransitioningTypes = true; 1007 | break; 1008 | } 1009 | const resp = await fetch(segment.url); 1010 | if (startGeneration !== generation) break; 1011 | 1012 | const bytes = await resp.arrayBuffer(); 1013 | if (startGeneration !== generation) break; 1014 | await lastPromise; 1015 | if (startGeneration !== generation) break; 1016 | if (transmuxer) { 1017 | transmuxer.push(new Uint8Array(bytes)); 1018 | transmuxer.flush(); 1019 | } 1020 | count++; 1021 | } catch (e) { 1022 | console.log(`Warning: failed to fetch: ${e}, stopping download early`) 1023 | if (videoMode === "live") { 1024 | pause(); 1025 | setTimeout(switchChannel, 1000); 1026 | } 1027 | break; 1028 | } 1029 | } 1030 | return count; 1031 | } 1032 | 1033 | function getRemainingBudget(incr) { 1034 | budgetEnd += incr; 1035 | const remaining = Math.max(0, budgetEnd - Date.now()); 1036 | if (remaining === 0) budgetEnd = Date.now(); 1037 | return remaining; 1038 | } 1039 | 1040 | const rebuffer = async function () { 1041 | const startGeneration = generation; 1042 | 1043 | const segments = await bufferLive(variants[variantIdx].url); 1044 | bufferPromise = downloadSegments(startGeneration, bufferPromise, segments); 1045 | 1046 | if (generation === startGeneration) { 1047 | rebufferTimer = setTimeout(rebuffer, getRemainingBudget(budget)); 1048 | } 1049 | }; 1050 | 1051 | function setVolume(vol) { 1052 | vol = Math.min(1, Math.max(0, vol)); 1053 | localStorage.setItem('twitch-dvr:vol', vol); 1054 | player.volume = vol; 1055 | volume.style.width = `${vol * 100 + handleRadius * 2}px`; 1056 | } 1057 | 1058 | let firstSegment = false; 1059 | 1060 | function resetTransmuxer() { 1061 | isTransitioningTypes = false; 1062 | if (transmuxer) transmuxer.off('data'); 1063 | transmuxer = new muxjs.mp4.Transmuxer(); 1064 | firstSegment = true; 1065 | transmuxer.on('data', (segment) => { 1066 | if (firstSegment) { 1067 | const data = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength); 1068 | data.set(segment.initSegment, 0); 1069 | data.set(segment.data, segment.initSegment.byteLength); 1070 | arrayOfBlobs.push(data); 1071 | firstSegment = false; 1072 | } else { 1073 | arrayOfBlobs.push(new Uint8Array(segment.data)); 1074 | } 1075 | appendToSourceBuffer(); 1076 | }); 1077 | } 1078 | 1079 | function setVariant(idx) { 1080 | if (!document.getElementById('quality-picker')) return; 1081 | idx = Math.max(0, Math.min(idx, variants.length - 1)); 1082 | localStorage.setItem('twitch-dvr:variant', idx); 1083 | variantIdx = idx 1084 | 1085 | const currTime = player.currentTime + vodOrigin; 1086 | 1087 | mediaSrc = new MediaSource(); 1088 | if (player.src) { 1089 | URL.revokeObjectURL(player.src); 1090 | } 1091 | clearTimers(); 1092 | sourceBuffer = null; 1093 | 1094 | if (!variants[idx]) return; 1095 | document.getElementById('quality').innerText = variants[idx].resolution; 1096 | 1097 | resetTransmuxer(); 1098 | player.src = URL.createObjectURL(mediaSrc); 1099 | 1100 | lastFetched = new Set(); 1101 | let variant = videoMode === 'live' ? variants[idx] : vodVariants[idx]; 1102 | if (videoMode === 'live') pause(); 1103 | 1104 | mediaSrc.addEventListener('sourceopen', function () { 1105 | sourceBuffer = mediaSrc.addSourceBuffer(`video/mp4; codecs=${variant.codecs}`); 1106 | sourceBuffer.addEventListener('updateend', () => { 1107 | afterBufferUpdate(); 1108 | appendToSourceBuffer(); 1109 | }); 1110 | sourceBuffer.addEventListener('error', () => { 1111 | pause(); 1112 | setTimeout(switchChannel, 1000); 1113 | console.warn('Failed to append to buffer'); 1114 | }); 1115 | budgetEnd = Date.now(); 1116 | if (videoMode === 'live') { 1117 | play(); 1118 | } else { 1119 | firstTime = true; 1120 | bufferVOD(variant, currTime, true); 1121 | } 1122 | }); 1123 | } 1124 | 1125 | function switchMode(mode) { 1126 | videoMode = mode; 1127 | budgetEnd = Date.now(); 1128 | resetTransmuxer(); 1129 | if (!document.getElementById('live')) return; 1130 | document.getElementById('live').className = 'control ' + mode; 1131 | if (mode === 'live') seekSlider.style.width = '100%'; 1132 | const clipButton = document.getElementById('clip'); 1133 | if (!clipButton) return; 1134 | if (!document.querySelector('.anon-user')) clipButton.style.display = 'block'; 1135 | } 1136 | 1137 | function formatTime(secs) { 1138 | secs = Math.floor(secs); 1139 | const hours = Math.floor(secs / 3600); 1140 | let mins = Math.floor(secs / 60) % 60; 1141 | secs = secs % 60; 1142 | if (secs < 10) secs = `0${secs}`; 1143 | if (hours > 0 && mins < 10) mins = `0${mins}`; 1144 | return hours > 0 ? `${hours}:${mins}:${secs}` : `${mins}:${secs}`; 1145 | } 1146 | 1147 | async function switchChannel() { 1148 | if (playerType === 'twitch') return; 1149 | resetTotalElapsedPromise(); 1150 | switchMode('live'); 1151 | 1152 | vodId = null; 1153 | channel = getChannelName(document.location.pathname); 1154 | document.getElementById('quality-picker').innerHTML = ''; 1155 | try { 1156 | variants = await getLiveM3U8(channel, clientId); 1157 | } catch (e) { 1158 | console.log(e); 1159 | uninstallPlayer(); 1160 | return; 1161 | } 1162 | playerInstalled = true; 1163 | 1164 | for (let i = 0; i < variants.length; i++) { 1165 | const v = variants[i]; 1166 | const picker = document.createElement('div'); 1167 | picker.className = 'picker'; 1168 | picker.innerText = v.resolution; 1169 | picker.addEventListener('click', () => { 1170 | setVariant(i); 1171 | }); 1172 | document.getElementById('quality-picker').appendChild(picker); 1173 | } 1174 | 1175 | const savedVariant = localStorage.getItem('twitch-dvr:variant'); 1176 | setVariant(savedVariant ? parseInt(savedVariant) : 0); 1177 | if (!document.querySelector('.anon-user')) document.getElementById('clip').style.display = 'block'; 1178 | 1179 | vodURLs = []; 1180 | vodVariants = await getVODUrl(channel, clientId); 1181 | for (const variant of vodVariants) { 1182 | const urlParts = variant.url.split('/'); 1183 | urlParts[urlParts.length - 1] = ''; 1184 | vodURLs.push(urlParts.join('/')); 1185 | } 1186 | } 1187 | 1188 | function getChannelName(url) { 1189 | const urlParts = url.split('/'); 1190 | return (urlParts.length === 3 && urlParts[1] === 'moderator') ? urlParts[2] : urlParts[1]; 1191 | } 1192 | 1193 | function isInChannel(url) { 1194 | const urlParts = url.split('/'); 1195 | if (urlParts.length === 3 && (urlParts[2] === 'videos' || urlParts[2] === 'schedule' || urlParts[2] === 'about' || urlParts[1] == 'moderator')) return true; 1196 | return urlParts.length === 2 && url !== '/' && url !== '/directory' && url !== '/search'; 1197 | } 1198 | 1199 | const seekStep = 10; 1200 | 1201 | async function fetchGQL(payload) { 1202 | const oauthToken = getOauthToken(); 1203 | const resp = await fetch('https://gql.twitch.tv/gql', { 1204 | method: 'POST', 1205 | headers: { 1206 | 'client-id': clientId, 1207 | 'Authorization': oauthToken ? `OAuth ${oauthToken}` : undefined, 1208 | }, 1209 | body: JSON.stringify(payload), 1210 | }); 1211 | return await resp.json(); 1212 | } 1213 | 1214 | async function createClip() { 1215 | let json = await fetchGQL([{ 1216 | operationName: 'VideoPlayerClipsButtonBroadcaster', 1217 | extensions: { 1218 | persistedQuery: { 1219 | version: 1, 1220 | sha256Hash: '730fb0ffd8f189610597747a011af437a122e842cd80db337c1ecf876a0da173', 1221 | }, 1222 | }, 1223 | variables: { 1224 | input: { 1225 | login: channel, 1226 | ownsVideoID: null, 1227 | } 1228 | } 1229 | }]); 1230 | const user = json[0].data.userByAttribute; 1231 | 1232 | json = await fetchGQL([{ 1233 | operationName: 'createClip', 1234 | extensions: { 1235 | persistedQuery: { 1236 | version: 1, 1237 | sha256Hash: '518982ccc596c07839a6188e075adc80475b7bc4606725f3011b640b87054ecf', 1238 | } 1239 | }, 1240 | variables: { 1241 | input: { 1242 | broadcastID: user.stream.id, 1243 | broadcasterID: user.id, 1244 | offsetSeconds: Math.round(adjustedTime - 4), 1245 | videoID: videoMode === 'live' ? undefined : vodId, 1246 | }, 1247 | }, 1248 | }]); 1249 | let clipURL = 'https://clips.twitch.tv/clips/user_restricted'; 1250 | if (json[0].data.createClip.clip) { 1251 | clipURL = `${json[0].data.createClip.clip.url}/edit`; 1252 | } 1253 | window.open(clipURL, '_blank'); 1254 | } 1255 | 1256 | function keyboardHandler(e) { 1257 | if (!player) return; 1258 | const nodeName = e.target.nodeName; 1259 | if (nodeName !== 'DIV' && nodeName !== 'BODY' && nodeName !== 'VIDEO') return; 1260 | if (e.target.attributes.role && e.target.attributes.role.value === 'textbox') return; 1261 | if (e.target.dataset.aTarget === 'chat-input') return; 1262 | switch (e.keyCode) { 1263 | case 37: 1264 | let newTime = videoMode === 'vod' ? vodOrigin + player.currentTime - seekStep : maxTime - seekStep; 1265 | if (tmpVodOrigin) newTime = tmpVodOrigin - seekStep; 1266 | if (maxTime - newTime < vodDeadzone + vodDeadzoneBuffer) { 1267 | newTime = maxTime - vodDeadzoneBuffer - vodDeadzone; 1268 | } 1269 | seekToTime(newTime); 1270 | break; 1271 | case 39: 1272 | if (videoMode === 'live') break; 1273 | seekToTime(tmpVodOrigin ? tmpVodOrigin + seekStep : vodOrigin + player.currentTime + seekStep); 1274 | break; 1275 | case 32: 1276 | if (paused) play(); 1277 | else pause(); 1278 | e.stopPropagation(); 1279 | break; 1280 | case 84: 1281 | if (e.altKey) { 1282 | toggleTheaterModeIcon(); 1283 | } 1284 | break; 1285 | case 188: 1286 | if (videoMode === 'live') break; 1287 | if (!player.buffered || !player.buffered.length) break; 1288 | const seekTime = player.currentTime - 1 / (vodVariants[variantIdx].framerate); 1289 | if (player.buffered.start(0) > seekTime) { 1290 | showToast("Seek backward to frame-by-frame further") 1291 | break; 1292 | } 1293 | pause(); 1294 | player.currentTime = seekTime; 1295 | break; 1296 | case 190: 1297 | if (videoMode === 'live') break; 1298 | if (!player.buffered || !player.buffered.length) break; 1299 | pause(); 1300 | player.currentTime += 1 / (vodVariants[variantIdx].framerate); 1301 | break; 1302 | } 1303 | } 1304 | 1305 | let playerContainer = null; 1306 | let installationTimer = null; 1307 | function uninstallPlayer() { 1308 | playerInstalled = false; 1309 | if (playerContainer && playerContainer.style) { 1310 | pause(); 1311 | playerContainer.parentElement.removeChild(playerContainer); 1312 | playerContainer = null; 1313 | } else { 1314 | if (installationTimer) { 1315 | clearTimeout(installationTimer); 1316 | installationTimer = null; 1317 | } 1318 | } 1319 | } 1320 | 1321 | document.addEventListener('keydown', keyboardHandler); 1322 | 1323 | async function main() { 1324 | let timerEl = null; 1325 | 1326 | function installPlayer() { 1327 | const videoContainer = document.querySelector('.video-player__container'); 1328 | 1329 | if (!videoContainer) { 1330 | installationTimer = setTimeout(installPlayer, 1000); 1331 | return; 1332 | } 1333 | 1334 | if (!isInChannel(document.location.pathname)) { 1335 | return; 1336 | } 1337 | 1338 | paused = true; 1339 | const playerSettings = document.createElement('div'); 1340 | playerSettings.id = 'settings'; 1341 | const switchMessage = playerType === 'dvr' ? 'Switch to Twitch Player' : 'Switch to DVR Player'; 1342 | playerSettings.innerHTML = ` 1343 |
Settings
1344 |
1345 |
${switchMessage}
1346 |
Reset Player
1347 |
1348 | `; 1349 | 1350 | playerContainer = document.createElement('div'); 1351 | playerContainer.id = 'player-container'; 1352 | playerContainer.className = playerType; 1353 | playerContainer.innerHTML = ` 1354 | 1355 |
1356 |
1357 |
1358 | 1359 | 1360 | 1361 | 1362 |
1363 |
1364 | 1365 | 1366 | 1367 | 1368 | 1369 |
1370 |
1371 | 1372 | 1373 | 1374 | 1375 |
1376 |
1377 |
1378 |
1379 |
1380 |
1381 |
1382 |
1383 |
1384 |
1385 |
1386 |
1387 |
1388 |
1389 |
1390 |
1391 |
1392 |
1393 |
1394 |
1395 |
1396 | 1397 |
1398 |
1399 |
1400 |
Go to live
1401 |
1402 |
1403 |
1404 |
1405 |
1406 |
1407 |
1408 |
1409 |
1410 |
1411 | 1412 | 1413 | 1414 | 1415 |
1416 |
1417 | 1418 | 1419 | 1420 | 1421 | 1422 | 1423 | 1424 |
1425 |
1426 |
1427 |
Click to unmute
1428 |
Test Toast
1429 |

Error rewinding stream



1430 | `; 1431 | 1432 | let lastKnownVolume; 1433 | function muteOrUnmute() { 1434 | if (player.volume === 0) { 1435 | setVolume(lastKnownVolume); 1436 | } else { 1437 | setVolume(0); 1438 | } 1439 | } 1440 | videoContainer.appendChild(playerContainer); 1441 | player = document.getElementById('player'); 1442 | volume = document.getElementById('volume-slider'); 1443 | seekTooltip = document.getElementById('seek-tooltip'); 1444 | seekTooltipText = document.getElementById('tooltip-text'); 1445 | seekContainer = document.getElementById('seek-container'); 1446 | seekSlider = document.getElementById('seek-slider'); 1447 | document.getElementById('play').addEventListener('click', play); 1448 | document.getElementById('pause').addEventListener('click', pause); 1449 | document.getElementById('fullscreen').addEventListener('click', toggleFullscreen); 1450 | playerContainer.addEventListener('dblclick', toggleFullscreen); 1451 | document.getElementById('quality').addEventListener('click', togglePicker); 1452 | document.getElementById('quality').addEventListener('dblclick', (e) => e.stopPropagation()); 1453 | document.getElementById('live').addEventListener('click', golive); 1454 | document.getElementById('clip').addEventListener('click', createClip); 1455 | document.getElementById('theater').addEventListener('click', toggleTheaterMode); 1456 | document.getElementById('volume').addEventListener('click', muteOrUnmute); 1457 | document.getElementById('mute-overlay').addEventListener('click', () => { 1458 | document.getElementById('mute-overlay').style.display = 'none'; 1459 | player.muted = false; 1460 | player.play(); 1461 | }); 1462 | 1463 | if (!document.getElementById('settings')) { 1464 | videoContainer.appendChild(playerSettings); 1465 | videoContainer.addEventListener('mousemove', showToggleForAWhile); 1466 | document.getElementById('settings-button').addEventListener('click', toggleSettings); 1467 | document.getElementById('toggle').addEventListener('click', togglePlayer); 1468 | document.getElementById('reset-player').addEventListener('click', () => { 1469 | uninstallPlayer(); 1470 | installPlayer(); 1471 | }); 1472 | } 1473 | 1474 | const savedVol = localStorage.getItem('twitch-dvr:vol'); 1475 | lastKnownVolume = savedVol; 1476 | player.volume = savedVol ? parseFloat(savedVol) : 1; 1477 | setVolume(player.volume); 1478 | switchChannel(); 1479 | 1480 | const volumeContainer = document.getElementById('volume-container'); 1481 | volumeContainer.addEventListener('mousedown', (ev) => { 1482 | const leftSide = ev.pageX - ev.offsetX; 1483 | setVolume((ev.offsetX - handleRadius) / 100); 1484 | 1485 | const mouseMove = (ev) => { 1486 | setVolume((ev.pageX - leftSide - handleRadius) / 100); 1487 | } 1488 | const mouseUp = (ev) => { 1489 | let vol = (ev.pageX - leftSide - handleRadius) / 100; 1490 | vol = Math.min(1, Math.max(0, vol)); 1491 | if (vol !== 0) lastKnownVolume = vol; 1492 | 1493 | document.removeEventListener('mousemove', mouseMove); 1494 | document.removeEventListener('mouseUp', mouseUp); 1495 | } 1496 | document.addEventListener('mousemove', mouseMove); 1497 | document.addEventListener('mouseup', mouseUp); 1498 | }); 1499 | 1500 | timerEl = document.getElementById('timer'); 1501 | 1502 | let lastSeekOffset = null; 1503 | updateSeekLabel = (seekOffset) => { 1504 | if (!seekOffset) seekOffset = lastSeekOffset; 1505 | else lastSeekOffset = seekOffset; 1506 | seekOffset = Math.max(handleRadius, seekOffset); 1507 | seekOffset = Math.min(getSeekWidth() - handleRadius, seekOffset); 1508 | 1509 | seekTooltip.style.left = `${seekOffset - 30}px`; 1510 | if (!maxTime) return; 1511 | 1512 | const adjustedTime = getTimeAtOffset(seekOffset); 1513 | if (maxTime - adjustedTime < vodDeadzone + vodDeadzoneBuffer) { 1514 | seekTooltipText.innerText = 'Live'; 1515 | } else { 1516 | seekTooltipText.innerText = formatTime(adjustedTime); 1517 | } 1518 | }; 1519 | 1520 | seekContainer.addEventListener('mousemove', (ev) => updateSeekLabel(ev.layerX)); 1521 | seekContainer.addEventListener('mousedown', onSeekDown); 1522 | } 1523 | 1524 | let currentUrl = document.location.pathname; 1525 | inChannelPage = isInChannel(currentUrl); 1526 | if (inChannelPage) installPlayer(); 1527 | 1528 | setInterval(() => { 1529 | if (document.location.pathname !== currentUrl) { 1530 | let prevChannel = null; 1531 | if (inChannelPage) { 1532 | prevChannel = currentUrl.split('/')[1]; 1533 | } 1534 | currentUrl = document.location.pathname; 1535 | inChannelPage = isInChannel(currentUrl); 1536 | if (inChannelPage) { 1537 | if (currentUrl.split('/')[1] === prevChannel) { 1538 | return; 1539 | } 1540 | if (!document.getElementById('player')) { 1541 | installPlayer(); 1542 | } else { 1543 | playerContainer.style.display = ''; 1544 | switchMode('live'); 1545 | pause(); 1546 | switchChannel(); 1547 | } 1548 | } else { 1549 | if (sourceBuffer) { 1550 | clearTimers(); 1551 | } 1552 | uninstallPlayer(); 1553 | } 1554 | } 1555 | if (!inChannelPage || !playerInstalled) return; 1556 | if (playerType === 'twitch') return; 1557 | 1558 | const adIframe = document.getElementById('amazon-video-ads-iframe'); 1559 | if (adIframe) adIframe.remove(); 1560 | 1561 | const videos = document.querySelectorAll('.video-player__container video'); 1562 | for (const video of videos) { 1563 | if (video.id !== 'player' && !video.paused) { 1564 | video.pause(); 1565 | } 1566 | } 1567 | 1568 | if (videoMode === 'live' && (paused || !sourceBuffer || !player.buffered.length)) return; 1569 | 1570 | const width = getSeekWidth(); 1571 | maxTime = (Date.now() - timeOrigin) / 1000 + timeOriginPlayerTime; 1572 | adjustedTime = videoMode === 'vod' ? vodOrigin + player.currentTime : maxTime; 1573 | 1574 | if (seeking) return; 1575 | if (videoMode === 'vod' && width) seekSlider.style.width = `${(width - 2 * handleRadius) * adjustedTime / maxTime + 2 * handleRadius}px`; 1576 | timerEl.innerText = formatTime(adjustedTime); 1577 | 1578 | if (paused || !sourceBuffer || !player.buffered.length) return; 1579 | 1580 | if (isTransitioningTypes && lastRealTime > 0) { 1581 | const realDiff = Date.now() - lastRealTime; 1582 | const playerDiff = player.currentTime - lastPlayerTime; 1583 | 1584 | if (realDiff - playerDiff * 1000 > 200) { 1585 | pause(); 1586 | resetTransmuxer(); 1587 | play(); 1588 | return; 1589 | } 1590 | } 1591 | 1592 | if (seekTooltip.offsetParent) updateSeekLabel(); 1593 | }, 1000); 1594 | } 1595 | 1596 | main(); 1597 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Twitch DVR player", 3 | "version": "1.4.13", 4 | "description": "Replaces the standard twitch player with a custom one that supports DVR.", 5 | "permissions": [ 6 | "https://*.twitch.tv/*", 7 | "https://*.ttvnw.net/*", 8 | "https://dqrpb9wgowsf5.cloudfront.net/*" 9 | ], 10 | "content_scripts": [ 11 | { 12 | "matches": [ 13 | "https://www.twitch.tv/*" 14 | ], 15 | "js": [ 16 | "mux.js", 17 | "index.js" 18 | ], 19 | "run_at": "document_idle" 20 | } 21 | ], 22 | "icons": { 23 | "48": "twitch-dvr@48.png", 24 | "128": "twitch-dvr.png" 25 | }, 26 | "manifest_version": 2 27 | } -------------------------------------------------------------------------------- /twitch-dvr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caeleel/twitch-dvr/d5694daa2c98b9510a830c3431806901b2087d0e/twitch-dvr.png -------------------------------------------------------------------------------- /twitch-dvr@48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caeleel/twitch-dvr/d5694daa2c98b9510a830c3431806901b2087d0e/twitch-dvr@48.png --------------------------------------------------------------------------------