Live video on the AT Protocol

Merge pull request #633 from streamplace/natb/bullet-hell

player: 'bullet comments' easter egg

authored by

natalie and committed by
GitHub
26896cfe 9db55824

+1590 -20
+27 -5
js/app/components/mobile/desktop-ui/bottom-controls.tsx
··· 2 2 Button, 3 3 PlayerUI, 4 4 View, 5 + useDanmuEnabled, 6 + useDanmuUnlocked, 5 7 usePlayerStore, 8 + useSetDanmuEnabled, 6 9 useTheme, 7 10 zero, 8 11 } from "@streamplace/components"; ··· 16 19 import { Platform, Pressable } from "react-native"; 17 20 import { VolumeSlider } from "./volume-slider"; 18 21 19 - const { gap, layout, p, r } = zero; 22 + import { Mu } from "./mu"; 23 + 24 + const { gap, layout, p, r, py, px } = zero; 20 25 21 26 interface BottomControlBarProps { 22 27 ingest: string | null; ··· 40 45 let { theme } = useTheme(); 41 46 const fullscreen = usePlayerStore((state) => state.fullscreen); 42 47 const setFullscreen = usePlayerStore((state) => state.setFullscreen); 48 + const danmuUnlocked = useDanmuUnlocked(); 49 + const danmuEnabled = useDanmuEnabled(); 50 + const setDanmuEnabled = useSetDanmuEnabled(); 43 51 44 52 return ( 45 53 <View ··· 61 69 </View> 62 70 </Pressable> 63 71 )} 64 - {ingest === null && ( 65 - <PlayerUI.ContextMenu 66 - dropdownPortalContainer={dropdownPortalContainer} 67 - /> 72 + {danmuUnlocked && ( 73 + <Pressable 74 + onPress={() => { 75 + setDanmuEnabled(!danmuEnabled); 76 + }} 77 + style={[px[0], r[1]]} 78 + > 79 + <Mu 80 + size={22} 81 + color={theme.colors.text} 82 + style={{ opacity: danmuEnabled ? 1 : 0.5 }} 83 + /> 84 + </Pressable> 68 85 )} 69 86 {Platform.OS === "web" && ( 70 87 <Pressable ··· 79 96 <Fullscreen color={theme.colors.text} /> 80 97 )} 81 98 </Pressable> 99 + )} 100 + {ingest === null && ( 101 + <PlayerUI.ContextMenu 102 + dropdownPortalContainer={dropdownPortalContainer} 103 + /> 82 104 )} 83 105 {/* if not web, then add the collapse chat controls here */} 84 106 {Platform.OS !== "web" && (
+28
js/app/components/mobile/desktop-ui/mu.tsx
··· 1 + import { G, Path, Svg, SvgProps } from "react-native-svg"; 2 + 3 + export interface MuProps extends SvgProps { 4 + size?: number | string; 5 + color?: string; 6 + strokeWidth?: number | string; 7 + } 8 + 9 + export const Mu = ({ 10 + size = 24, 11 + color = "#000000", 12 + strokeWidth = 10.9, 13 + ...props 14 + }: MuProps) => ( 15 + <Svg width={size} height={size} viewBox="0 0 230 220" {...props}> 16 + <G 17 + strokeLinecap="round" 18 + fillRule="evenodd" 19 + stroke={color} 20 + strokeWidth={strokeWidth} 21 + fill={color} 22 + > 23 + <Path d="M 13.756 65.7 L 53.756 65.7 Q 55.756 65.7 57.256 64.2 Q 58.756 62.7 58.756 60.7 L 58.756 22.2 Q 58.756 20.2 57.256 18.7 Q 55.756 17.2 53.756 17.2 L 3.506 17.2 Q 2.006 17.2 1.006 16.2 Q 0.006 15.2 0.006 13.45 Q 0.006 11.95 1.006 10.95 Q 2.006 9.95 3.506 9.95 L 56.506 9.95 Q 60.756 9.95 63.506 12.7 Q 66.256 15.45 66.256 19.7 L 66.256 63.45 Q 66.256 67.45 63.506 70.325 Q 60.756 73.2 56.506 73.2 L 16.256 73.2 Q 14.006 73.2 12.506 74.7 Q 11.006 76.2 11.006 78.2 Q 10.756 89.7 10.506 98.575 Q 10.256 107.45 9.756 117.45 A 4.864 4.864 0 0 0 9.717 118.054 A 4.167 4.167 0 0 0 10.881 120.95 Q 12.256 122.45 14.506 122.45 L 60.256 122.45 Q 63.006 122.45 64.881 124.325 Q 66.756 126.2 66.756 129.7 A 1579.753 1579.753 0 0 1 65.295 155.188 Q 64.543 166.519 63.737 175.256 A 494.991 494.991 0 0 1 63.506 177.7 Q 61.756 195.7 59.506 204.7 A 39.785 39.785 0 0 1 55.486 215.384 A 14.939 14.939 0 0 1 54.006 217.45 Q 51.506 220.2 48.506 221.075 Q 45.506 221.95 40.506 222.2 Q 36.006 222.45 28.506 222.2 Q 21.006 221.95 11.756 221.45 A 5.389 5.389 0 0 1 10.79 221.197 A 5.13 5.13 0 0 1 9.131 220.2 Q 8.006 219.2 7.506 217.45 A 3.871 3.871 0 0 1 7.45 216.814 A 2.355 2.355 0 0 1 8.131 215.075 Q 9.006 214.2 10.506 214.2 Q 20.006 214.95 27.881 215.2 Q 35.756 215.45 39.006 215.45 Q 42.506 215.45 44.381 215.075 Q 46.256 214.7 47.756 212.95 A 17.691 17.691 0 0 0 51.389 205.661 A 55.6 55.6 0 0 0 52.381 201.825 A 144.371 144.371 0 0 0 54.411 189.832 A 374.249 374.249 0 0 0 55.881 176.95 A 735.769 735.769 0 0 0 57.475 157.825 A 1519.1 1519.1 0 0 0 59.006 133.45 A 5.968 5.968 0 0 0 59.061 132.663 A 2.91 2.91 0 0 0 55.506 129.7 L 11.506 129.7 Q 7.506 129.7 4.881 126.825 A 8.876 8.876 0 0 1 2.484 120.637 A 11.015 11.015 0 0 1 2.506 119.95 Q 2.756 113.95 3.006 108.7 Q 3.256 103.45 3.506 98.45 Q 3.756 93.45 3.881 87.95 Q 4.006 82.45 4.006 75.45 Q 4.006 71.2 6.881 68.45 Q 9.756 65.7 13.756 65.7 Z M 99.006 49.95 L 200.256 49.95 Q 204.506 49.95 207.256 52.7 Q 210.006 55.45 210.006 59.7 L 210.006 127.95 Q 210.006 131.95 207.256 134.825 Q 204.506 137.7 200.256 137.7 L 99.006 137.7 Q 95.006 137.7 92.131 134.825 Q 89.256 131.95 89.256 127.95 L 89.256 59.7 Q 89.256 55.45 92.131 52.7 Q 95.006 49.95 99.006 49.95 Z M 145.506 53.95 L 153.006 53.95 L 153.006 218.95 Q 153.006 220.7 152.006 221.7 Q 151.006 222.7 149.256 222.7 Q 147.506 222.7 146.506 221.7 Q 145.506 220.7 145.506 218.95 L 145.506 53.95 Z M 77.506 163.95 L 219.006 163.95 Q 220.756 163.95 221.756 164.95 Q 222.756 165.95 222.756 167.7 Q 222.756 169.45 221.756 170.45 Q 220.756 171.45 219.006 171.45 L 77.506 171.45 Q 75.756 171.45 74.756 170.45 Q 73.756 169.45 73.756 167.7 Q 73.756 165.95 74.756 164.95 Q 75.756 163.95 77.506 163.95 Z M 96.506 97.45 L 96.506 125.45 Q 96.506 127.45 98.006 128.95 Q 99.506 130.45 101.506 130.45 L 197.506 130.45 Q 199.506 130.45 201.006 128.95 Q 202.506 127.45 202.506 125.45 L 202.506 97.45 L 96.506 97.45 Z M 96.506 62.45 L 96.506 89.95 L 202.506 89.95 L 202.506 62.45 Q 202.506 60.45 201.006 58.95 Q 199.506 57.45 197.506 57.45 L 101.506 57.45 Q 99.506 57.45 98.006 58.95 Q 96.506 60.45 96.506 62.45 Z M 166.506 55.2 L 161.006 51.95 Q 168.756 41.7 176.881 27.7 A 383.321 383.321 0 0 0 189.09 5.114 A 264.812 264.812 0 0 0 190.506 2.2 Q 191.006 0.7 192.506 0.2 Q 194.006 -0.3 195.756 0.45 Q 197.256 0.95 197.756 2.45 Q 198.256 3.95 197.506 5.2 Q 190.506 17.95 182.506 31.45 Q 174.506 44.95 166.506 55.2 Z M 107.672 1.117 A 4.458 4.458 0 0 0 106.006 1.45 Q 104.756 1.95 104.381 2.95 Q 104.006 3.95 104.756 4.95 Q 111.756 13.95 117.006 22.825 Q 122.256 31.7 126.006 39.95 Q 126.506 41.2 127.756 41.7 Q 129.006 42.2 130.256 41.45 Q 132.006 40.7 132.506 39.2 Q 133.006 37.7 132.256 36.45 Q 128.506 28.45 123.256 19.95 Q 118.006 11.45 110.756 2.45 Q 109.756 1.45 108.506 1.2 A 4.24 4.24 0 0 0 107.672 1.117 Z" /> 24 + </G> 25 + </Svg> 26 + ); 27 + 28 + export default Mu;
+434 -3
js/app/components/settings/settings.tsx
··· 1 - import { Button, Input, Text, View, zero } from "@streamplace/components"; 1 + import { 2 + Button, 3 + Input, 4 + Slider, 5 + Text, 6 + useDanmuSettings, 7 + View, 8 + zero, 9 + } from "@streamplace/components"; 2 10 import AQLink from "components/aqlink"; 3 11 import { 4 12 createServerSettingsRecord, ··· 10 18 import useStreamplaceNode from "hooks/useStreamplaceNode"; 11 19 import { useEffect, useState } from "react"; 12 20 import { useTranslation } from "react-i18next"; 13 - import { ScrollView, Switch } from "react-native"; 21 + import { 22 + Platform, 23 + ScrollView, 24 + Switch, 25 + useWindowDimensions, 26 + } from "react-native"; 14 27 import { useAppDispatch, useAppSelector } from "store/hooks"; 15 28 import { Updates } from "./updates"; 16 29 import WebhookManager from "./webhook-manager"; ··· 73 86 { alignItems: "stretch" }, 74 87 zero.layout.flex.justify.start, 75 88 zero.w.percent[100], 76 - zero.gap.all[4], 89 + zero.gap.all[6], 77 90 ]} 78 91 > 79 92 <View ··· 193 206 <Text style={[{ fontSize: 16 }]}>→</Text> 194 207 </View> 195 208 </AQLink> 209 + 210 + <DanmuSettings /> 196 211 </View> 197 212 </View> 198 213 </ScrollView> ··· 259 274 </View> 260 275 ); 261 276 }; 277 + 278 + const DanmuSettings = () => { 279 + const { 280 + danmuUnlocked, 281 + danmuEnabled, 282 + danmuOpacity, 283 + danmuSpeed, 284 + danmuLaneCount, 285 + danmuMaxMessages, 286 + setDanmuEnabled, 287 + setDanmuOpacity, 288 + setDanmuSpeed, 289 + setDanmuLaneCount, 290 + setDanmuMaxMessages, 291 + } = useDanmuSettings(); 292 + 293 + const { width } = useWindowDimensions(); 294 + const isNarrowScreen = width < 550; 295 + 296 + if (!danmuUnlocked) return null; 297 + 298 + return ( 299 + <View style={[{ alignItems: "stretch" }, zero.gap.all[4]]}> 300 + <View 301 + style={[ 302 + { flexDirection: "row" }, 303 + { alignItems: "flex-start" }, 304 + { justifyContent: "flex-start" }, 305 + ]} 306 + > 307 + <View style={[{ flex: 1 }, { paddingRight: 12 }]}> 308 + <Text size="xl">Enable Danmu</Text> 309 + <Text size="lg" color="muted"> 310 + Show "bullet comments" flying across the video. 311 + </Text> 312 + </View> 313 + <Switch value={danmuEnabled} onValueChange={setDanmuEnabled} /> 314 + </View> 315 + 316 + {danmuEnabled && ( 317 + <> 318 + <View style={[zero.gap.all[6]]}> 319 + <View 320 + style={[ 321 + { 322 + flexDirection: isNarrowScreen ? "column" : "row", 323 + justifyContent: "space-between", 324 + alignItems: "flex-start", 325 + }, 326 + zero.gap.all[2], 327 + ]} 328 + > 329 + <View 330 + style={[ 331 + { 332 + flexDirection: "row", 333 + alignItems: "center", 334 + justifyContent: "flex-start", 335 + }, 336 + zero.gap.all[2], 337 + ]} 338 + > 339 + <Text size="lg">Opacity:</Text> 340 + <Input 341 + value={String(danmuOpacity)} 342 + onChangeText={(text) => { 343 + const val = parseInt(text) || 0; 344 + setDanmuOpacity(Math.min(100, Math.max(0, val))); 345 + }} 346 + containerStyle={{ width: 60 }} 347 + keyboardType="number-pad" 348 + /> 349 + <Text size="lg">%</Text> 350 + </View> 351 + <View 352 + style={[ 353 + { 354 + flexDirection: "row", 355 + alignItems: "center", 356 + }, 357 + zero.gap.all[2], 358 + ]} 359 + > 360 + {[0, 25, 50, 75, 100].map((value) => ( 361 + <Button 362 + key={value} 363 + onPress={() => setDanmuOpacity(value)} 364 + variant={danmuOpacity === value ? "primary" : "secondary"} 365 + size="pill" 366 + > 367 + <Text size="lg">{value}</Text> 368 + </Button> 369 + ))} 370 + </View> 371 + </View> 372 + {Platform.OS === "web" && ( 373 + <Slider.Root 374 + // i think they typed this wrong in the lib? 375 + value={[danmuOpacity] as any} 376 + min={0} 377 + max={100} 378 + step={5} 379 + onValueChange={(vals) => setDanmuOpacity(vals[0])} 380 + style={{ width: "100%", height: 40 }} 381 + > 382 + <Slider.Track 383 + style={{ 384 + height: 4, 385 + backgroundColor: "#374151", 386 + borderRadius: 2, 387 + width: "100%", 388 + }} 389 + > 390 + <Slider.Range 391 + style={{ 392 + height: 4, 393 + backgroundColor: "#3b82f6", 394 + borderRadius: 2, 395 + }} 396 + /> 397 + <Slider.Thumb 398 + style={{ 399 + width: 20, 400 + height: 20, 401 + borderRadius: 10, 402 + backgroundColor: "#3b82f6", 403 + transform: [{ translateY: -8 }], 404 + }} 405 + /> 406 + </Slider.Track> 407 + </Slider.Root> 408 + )} 409 + </View> 410 + 411 + <View style={[zero.gap.all[6]]}> 412 + <View 413 + style={[ 414 + { 415 + flexDirection: isNarrowScreen ? "column" : "row", 416 + justifyContent: "space-between", 417 + alignItems: "flex-start", 418 + }, 419 + zero.gap.all[2], 420 + ]} 421 + > 422 + <View 423 + style={[ 424 + { 425 + flexDirection: "row", 426 + alignItems: "center", 427 + }, 428 + zero.gap.all[2], 429 + ]} 430 + > 431 + <Text size="lg">Speed: </Text> 432 + <Input 433 + value={String(danmuSpeed)} 434 + onChangeText={(text) => { 435 + const val = parseFloat(text) || 0; 436 + setDanmuSpeed(Math.min(3, Math.max(0.1, val))); 437 + }} 438 + containerStyle={{ width: 60 }} 439 + keyboardType="numeric" 440 + /> 441 + <Text size="lg">×</Text> 442 + </View> 443 + <View 444 + style={[ 445 + { 446 + flexDirection: "row", 447 + alignItems: "center", 448 + }, 449 + zero.gap.all[2], 450 + ]} 451 + > 452 + {[ 453 + { label: "0.5×", value: 0.5 }, 454 + { label: "1×", value: 1 }, 455 + { label: "1.5×", value: 1.5 }, 456 + { label: "2×", value: 2 }, 457 + ].map(({ label, value }) => ( 458 + <Button 459 + key={value} 460 + onPress={() => setDanmuSpeed(value)} 461 + variant={danmuSpeed === value ? "primary" : "secondary"} 462 + size="pill" 463 + > 464 + <Text size="lg">{label}</Text> 465 + </Button> 466 + ))} 467 + </View> 468 + </View> 469 + {Platform.OS === "web" && ( 470 + <Slider.Root 471 + value={[danmuSpeed] as any} 472 + min={0.5} 473 + max={2} 474 + step={0.1} 475 + onValueChange={(vals) => setDanmuSpeed(vals[0])} 476 + style={{ width: "100%", height: 40 }} 477 + > 478 + <Slider.Track 479 + style={{ 480 + height: 4, 481 + backgroundColor: "#374151", 482 + borderRadius: 2, 483 + width: "100%", 484 + }} 485 + > 486 + <Slider.Range 487 + style={{ 488 + height: 4, 489 + backgroundColor: "#3b82f6", 490 + borderRadius: 2, 491 + }} 492 + /> 493 + <Slider.Thumb 494 + style={{ 495 + width: 20, 496 + height: 20, 497 + borderRadius: 10, 498 + backgroundColor: "#3b82f6", 499 + transform: [{ translateY: -8 }], 500 + }} 501 + /> 502 + </Slider.Track> 503 + </Slider.Root> 504 + )} 505 + </View> 506 + 507 + <View style={[zero.gap.all[6]]}> 508 + <View 509 + style={[ 510 + { 511 + flexDirection: isNarrowScreen ? "column" : "row", 512 + justifyContent: "space-between", 513 + alignItems: "flex-start", 514 + }, 515 + zero.gap.all[2], 516 + ]} 517 + > 518 + <View 519 + style={[ 520 + { 521 + flexDirection: "row", 522 + alignItems: "center", 523 + }, 524 + zero.gap.all[2], 525 + ]} 526 + > 527 + <Text size="lg">Lanes: </Text> 528 + <Input 529 + value={String(danmuLaneCount)} 530 + onChangeText={(text) => { 531 + const val = parseInt(text) || 0; 532 + setDanmuLaneCount(Math.min(20, Math.max(4, val))); 533 + }} 534 + containerStyle={{ width: 60 }} 535 + keyboardType="number-pad" 536 + /> 537 + </View> 538 + <View 539 + style={[ 540 + { 541 + flexDirection: "row", 542 + alignItems: "center", 543 + }, 544 + zero.gap.all[2], 545 + ]} 546 + > 547 + {[6, 8, 10, 12, 15].map((value) => ( 548 + <Button 549 + key={value} 550 + onPress={() => setDanmuLaneCount(value)} 551 + variant={danmuLaneCount === value ? "primary" : "secondary"} 552 + size="pill" 553 + > 554 + <Text size="lg">{value}</Text> 555 + </Button> 556 + ))} 557 + </View> 558 + </View> 559 + {Platform.OS === "web" && ( 560 + <Slider.Root 561 + value={[danmuLaneCount] as any} 562 + min={4} 563 + max={20} 564 + step={1} 565 + onValueChange={(vals) => setDanmuLaneCount(vals[0])} 566 + style={{ width: "100%", height: 40 }} 567 + > 568 + <Slider.Track 569 + style={{ 570 + height: 4, 571 + backgroundColor: "#374151", 572 + borderRadius: 2, 573 + width: "100%", 574 + }} 575 + > 576 + <Slider.Range 577 + style={{ 578 + height: 4, 579 + backgroundColor: "#3b82f6", 580 + borderRadius: 2, 581 + }} 582 + /> 583 + <Slider.Thumb 584 + style={{ 585 + width: 20, 586 + height: 20, 587 + borderRadius: 10, 588 + backgroundColor: "#3b82f6", 589 + transform: [{ translateY: -8 }], 590 + }} 591 + /> 592 + </Slider.Track> 593 + </Slider.Root> 594 + )} 595 + </View> 596 + 597 + <View style={[zero.gap.all[6]]}> 598 + <View 599 + style={[ 600 + { 601 + flexDirection: isNarrowScreen ? "column" : "row", 602 + justifyContent: "space-between", 603 + alignItems: "flex-start", 604 + }, 605 + zero.gap.all[2], 606 + ]} 607 + > 608 + <View 609 + style={[ 610 + { 611 + flexDirection: "row", 612 + alignItems: "center", 613 + }, 614 + zero.gap.all[2], 615 + ]} 616 + > 617 + <Text size="lg">Max Messages: </Text> 618 + <Input 619 + value={String(danmuMaxMessages)} 620 + onChangeText={(text) => { 621 + const val = parseInt(text) || 0; 622 + setDanmuMaxMessages(Math.min(200, Math.max(5, val))); 623 + }} 624 + containerStyle={{ width: 60 }} 625 + keyboardType="number-pad" 626 + /> 627 + </View> 628 + <View 629 + style={[ 630 + { 631 + flexDirection: "row", 632 + alignItems: "center", 633 + }, 634 + zero.gap.all[2], 635 + ]} 636 + > 637 + {[10, 25, 50, 100].map((value) => ( 638 + <Button 639 + key={value} 640 + onPress={() => setDanmuMaxMessages(value)} 641 + variant={ 642 + danmuMaxMessages === value ? "primary" : "secondary" 643 + } 644 + size="pill" 645 + > 646 + <Text size="lg">{value}</Text> 647 + </Button> 648 + ))} 649 + </View> 650 + </View> 651 + {Platform.OS === "web" && ( 652 + <Slider.Root 653 + value={[danmuMaxMessages] as any} 654 + min={5} 655 + max={200} 656 + step={5} 657 + onValueChange={(vals) => setDanmuMaxMessages(vals[0])} 658 + style={{ width: "100%", height: 40 }} 659 + > 660 + <Slider.Track 661 + style={{ 662 + height: 4, 663 + backgroundColor: "#374151", 664 + borderRadius: 2, 665 + width: "100%", 666 + }} 667 + > 668 + <Slider.Range 669 + style={{ 670 + height: 4, 671 + backgroundColor: "#3b82f6", 672 + borderRadius: 2, 673 + }} 674 + /> 675 + <Slider.Thumb 676 + style={{ 677 + width: 20, 678 + height: 20, 679 + borderRadius: 10, 680 + backgroundColor: "#3b82f6", 681 + transform: [{ translateY: -8 }], 682 + }} 683 + /> 684 + </Slider.Track> 685 + </Slider.Root> 686 + )} 687 + </View> 688 + </> 689 + )} 690 + </View> 691 + ); 692 + };
+37
js/app/components/settings/updates.native.tsx
··· 1 1 import { 2 2 Button, 3 3 Text, 4 + useDanmuUnlocked, 5 + useSetDanmuUnlocked, 4 6 useTheme, 5 7 useToast, 6 8 zero, ··· 11 13 import { Platform, TouchableOpacity, View } from "react-native"; 12 14 import pkg from "../../package.json"; 13 15 16 + const UNLOCK_TAP_COUNT = 5; 17 + 14 18 export function Updates() { 15 19 const theme = useTheme(); 16 20 const version = pkg.version; ··· 22 26 console.log(`updateInfo: ${JSON.stringify(updateInfo)}`); 23 27 24 28 const [checked, setChecked] = useState(false); 29 + const [tapCount, setTapCount] = useState(0); 30 + const danmuUnlocked = useDanmuUnlocked(); 31 + const setDanmuUnlocked = useSetDanmuUnlocked(); 25 32 26 33 useEffect(() => { 27 34 if (isUpdateAvailable && checked) { 28 35 ExpoUpdates.fetchUpdateAsync(); 29 36 } 30 37 }, [isUpdateAvailable, checked]); 38 + 39 + const handleVersionPress = () => { 40 + if (danmuUnlocked) { 41 + toast.show("You are already a developer", undefined, { 42 + duration: 2, 43 + variant: "info", 44 + actionLabel: "Stop being a developer", 45 + onAction: () => { 46 + setDanmuUnlocked(false); 47 + toast.show("You are no longer a developer", undefined, { 48 + duration: 2, 49 + variant: "info", 50 + }); 51 + }, 52 + }); 53 + return; 54 + } 55 + 56 + const newCount = tapCount + 1; 57 + setTapCount(newCount); 58 + 59 + if (newCount >= UNLOCK_TAP_COUNT) { 60 + setDanmuUnlocked(true); 61 + toast.show("You are now a developer", "have fun! lol", { 62 + duration: 20, 63 + variant: "success", 64 + }); 65 + setTapCount(0); 66 + } 67 + }; 31 68 32 69 // If true, we show the button to download and run the update 33 70 const buttonText = isUpdateAvailable
+38 -5
js/app/components/settings/updates.tsx
··· 1 - import { Text } from "@streamplace/components"; 2 - import { useTranslation } from "react-i18next"; 3 - import { View } from "react-native"; 1 + import { 2 + Text, 3 + useDanmuUnlocked, 4 + useSetDanmuUnlocked, 5 + useToast, 6 + useTranslation, 7 + } from "@streamplace/components"; 8 + import { useState } from "react"; 9 + import { Pressable, View } from "react-native"; 4 10 import pkg from "../../package.json"; 11 + 12 + const UNLOCK_TAP_COUNT = 5; 5 13 6 14 // maybe someday some PWA update stuff will live here 7 15 export function Updates() { 8 16 const { t } = useTranslation("settings"); 17 + const [tapCount, setTapCount] = useState(0); 18 + const danmuUnlocked = useDanmuUnlocked(); 19 + const setDanmuUnlocked = useSetDanmuUnlocked(); 20 + const toast = useToast(); 21 + 22 + const handlePress = () => { 23 + if (danmuUnlocked) { 24 + toast.show("You are already a developer", "what are you doing???"); 25 + return; 26 + } 27 + 28 + const newCount = tapCount + 1; 29 + console.log("new tap count"); 30 + setTapCount(newCount); 31 + 32 + if (newCount >= UNLOCK_TAP_COUNT) { 33 + setDanmuUnlocked(true); 34 + toast.show("You are now a developer", "have fun! lol", { 35 + duration: 20, 36 + variant: "success", 37 + }); 38 + setTapCount(0); 39 + } 40 + }; 41 + 9 42 return ( 10 43 <View 11 44 style={[ ··· 14 47 { paddingVertical: 24 }, 15 48 ]} 16 49 > 17 - <View> 50 + <Pressable onPress={handlePress}> 18 51 <Text 19 52 size="2xl" 20 53 style={[ ··· 27 60 > 28 61 {t("app-version", { version: pkg.version })} 29 62 </Text> 30 - </View> 63 + </Pressable> 31 64 </View> 32 65 ); 33 66 }
+12
js/app/src/router.tsx
··· 84 84 ReanimatedLogLevel, 85 85 useAnimatedStyle, 86 86 } from "react-native-reanimated"; 87 + import DanmuOBSScreen from "./screens/danmu-obs"; 87 88 import MobileGoLive from "./screens/mobile-go-live"; 88 89 import MobileStream from "./screens/mobile-stream"; 89 90 store.dispatch(loadStateFromStorage()); ··· 125 126 InfoWidgetEmbed: undefined; 126 127 LegacyStream: { user: string }; 127 128 MobileGoLive: undefined; 129 + DanmuOBS: { user: string }; 128 130 }; 129 131 130 132 declare global { ··· 166 168 InfoWidgetEmbed: "info-widget", 167 169 LegacyStream: "legacy/:user", 168 170 MobileGoLive: "mobile-golive", 171 + DanmuOBS: "widgets/:user/danmu", 169 172 }, 170 173 }, 171 174 }; ··· 592 595 <Drawer.Screen 593 596 name="InfoWidgetEmbed" 594 597 component={InfoWidgetEmbed} 598 + options={{ 599 + drawerLabel: () => null, 600 + drawerItemStyle: { display: "none" }, 601 + headerShown: false, 602 + }} 603 + /> 604 + <Drawer.Screen 605 + name="DanmuOBS" 606 + component={DanmuOBSScreen} 595 607 options={{ 596 608 drawerLabel: () => null, 597 609 drawerItemStyle: { display: "none" },
+92
js/app/src/screens/danmu-obs.tsx
··· 1 + import { 2 + DanmuOverlayOBS, 3 + LivestreamProvider, 4 + PlayerProvider, 5 + usePlayerStore, 6 + } from "@streamplace/components"; 7 + import { 8 + setSidebarHidden, 9 + setSidebarUnhidden, 10 + } from "features/base/sidebarSlice"; 11 + import { useEffect } from "react"; 12 + import { Platform, View } from "react-native"; 13 + import { useAppDispatch } from "store/hooks"; 14 + 15 + const isWeb = Platform.OS === "web"; 16 + 17 + interface DanmuParams { 18 + opacity?: number; 19 + speed?: number; 20 + laneCount?: number; 21 + maxMessages?: number; 22 + enabled?: boolean; 23 + } 24 + 25 + const parseDanmuParams = (query: URLSearchParams): DanmuParams => { 26 + const params: DanmuParams = {}; 27 + 28 + const opacity = query.get("opacity"); 29 + if (opacity) params.opacity = parseInt(opacity); 30 + 31 + const speed = query.get("speed"); 32 + if (speed) params.speed = parseFloat(speed); 33 + 34 + const laneCount = query.get("laneCount"); 35 + if (laneCount) params.laneCount = parseInt(laneCount); 36 + 37 + const maxMessages = query.get("maxMessages"); 38 + if (maxMessages) params.maxMessages = parseInt(maxMessages); 39 + 40 + const enabled = query.get("enabled"); 41 + if (enabled !== null) params.enabled = enabled !== "false"; 42 + 43 + return params; 44 + }; 45 + 46 + export default function DanmuOBSScreen({ route }) { 47 + const user = route.params?.user; 48 + const dispatch = useAppDispatch(); 49 + 50 + let danmuParams: DanmuParams = {}; 51 + if (isWeb) { 52 + danmuParams = parseDanmuParams(new URLSearchParams(window.location.search)); 53 + } 54 + 55 + if (typeof user !== "string") { 56 + return <View />; 57 + } 58 + 59 + useEffect(() => { 60 + dispatch(setSidebarHidden()); 61 + () => { 62 + // on unmount, unhide the sidebar 63 + dispatch(setSidebarUnhidden()); 64 + }; 65 + }, []); 66 + 67 + return ( 68 + <LivestreamProvider src={user}> 69 + <PlayerProvider> 70 + <DanmuOBSInner user={user} {...danmuParams} /> 71 + </PlayerProvider> 72 + </LivestreamProvider> 73 + ); 74 + } 75 + 76 + export function DanmuOBSInner({ 77 + user, 78 + ...danmuParams 79 + }: { 80 + user: string; 81 + } & DanmuParams) { 82 + const setSrc = usePlayerStore((x) => x.setSrc); 83 + useEffect(() => { 84 + setSrc(user); 85 + }, [user]); 86 + 87 + return ( 88 + <View style={{ position: "absolute", width: "100%", height: "100%" }}> 89 + <DanmuOverlayOBS channelDid={user} {...danmuParams} /> 90 + </View> 91 + ); 92 + }
+182
js/components/src/components/danmu/danmu-message.tsx
··· 1 + import { memo, useEffect, useState } from "react"; 2 + import { LayoutChangeEvent, StyleSheet } from "react-native"; 3 + import Animated, { 4 + Easing, 5 + runOnJS, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withTiming, 9 + } from "react-native-reanimated"; 10 + import { ChatMessageViewHydrated } from "streamplace"; 11 + import { Text } from "../ui"; 12 + import { baseDuration, mapRange, MAX_DURATION, MIN_DURATION } from "./math"; 13 + 14 + interface DanmuMessageProps { 15 + message: ChatMessageViewHydrated; 16 + lane: number; 17 + laneHeight: number; 18 + videoTop: number; 19 + opacity: number; 20 + fontSize: number; 21 + speed: number; 22 + containerWidth: number; 23 + containerHeight: number; 24 + onComplete: (messageId: string) => void; 25 + onWidthMeasured?: (messageId: string, width: number) => void; 26 + } 27 + 28 + export const DanmuMessage = memo( 29 + ({ 30 + message, 31 + lane, 32 + laneHeight, 33 + videoTop, 34 + opacity, 35 + fontSize, 36 + speed, 37 + containerWidth, 38 + containerHeight, 39 + onComplete, 40 + onWidthMeasured, 41 + }: DanmuMessageProps) => { 42 + const translateX = useSharedValue(containerWidth); 43 + const [messageWidth, setMessageWidth] = useState(0); 44 + const [animationStartTime, setAnimationStartTime] = useState<number | null>( 45 + null, 46 + ); 47 + const [totalDuration, setTotalDuration] = useState(0); 48 + 49 + const getRgbColor = ( 50 + color: { 51 + red: number; 52 + green: number; 53 + blue: number; 54 + } = { red: 123, green: 123, blue: 123 }, 55 + ) => { 56 + const red = mapRange(color.red, 0, 255, 100, 230); 57 + const green = mapRange(color.green, 0, 255, 100, 230); 58 + const blue = mapRange(color.blue, 0, 255, 100, 230); 59 + 60 + return `rgb(${Math.round(red)}, ${Math.round(green)}, ${Math.round(blue)})`; 61 + }; 62 + 63 + const handleLayout = (event: LayoutChangeEvent) => { 64 + const width = event.nativeEvent.layout.width; 65 + if (width > 0) { 66 + setMessageWidth(width); 67 + if (onWidthMeasured) { 68 + onWidthMeasured(message.uri, width); 69 + } 70 + } 71 + }; 72 + 73 + useEffect(() => { 74 + if (messageWidth === 0) return; // Wait for layout measurement 75 + 76 + const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 77 + 78 + // Calculate how much time has elapsed if animation already started 79 + let remainingDuration = duration; 80 + let startPosition = containerWidth; 81 + 82 + if (animationStartTime !== null) { 83 + const elapsed = Date.now() - animationStartTime; 84 + const progress = Math.min(elapsed / totalDuration, 1); 85 + remainingDuration = duration * (1 - progress); 86 + startPosition = 87 + containerWidth + progress * (-messageWidth - containerWidth); 88 + } else { 89 + setAnimationStartTime(Date.now()); 90 + setTotalDuration(duration); 91 + } 92 + 93 + if (__DEV__) 94 + console.log( 95 + `[danmu] animation started: "${message.record.text}" (duration: ${duration.toFixed(0)}ms, remaining: ${remainingDuration.toFixed(0)}ms, speed: ${speed}x)`, 96 + ); 97 + 98 + translateX.value = startPosition; 99 + 100 + translateX.value = withTiming( 101 + -messageWidth, 102 + { 103 + duration: remainingDuration, 104 + easing: Easing.linear, 105 + }, 106 + (finished) => { 107 + if (finished) { 108 + runOnJS(onComplete)(message.uri); 109 + } 110 + }, 111 + ); 112 + }, [ 113 + messageWidth, 114 + containerWidth, 115 + speed, 116 + message.uri, 117 + message.record.text.length, 118 + lane, 119 + onComplete, 120 + ]); 121 + 122 + const animatedStyle = useAnimatedStyle(() => { 123 + return { 124 + transform: [{ translateX: translateX.value }], 125 + }; 126 + }); 127 + 128 + return ( 129 + <Animated.View 130 + style={[ 131 + styles.container, 132 + { 133 + top: videoTop + lane * laneHeight, 134 + opacity: opacity / 100, 135 + }, 136 + animatedStyle, 137 + ]} 138 + onLayout={handleLayout} 139 + > 140 + <Text 141 + style={[ 142 + styles.text, 143 + { 144 + fontSize, 145 + color: getRgbColor(message.chatProfile?.color), 146 + }, 147 + ]} 148 + numberOfLines={1} 149 + > 150 + {message.record.text} 151 + </Text> 152 + </Animated.View> 153 + ); 154 + }, 155 + (prevProps, nextProps) => { 156 + return ( 157 + prevProps.message.uri === nextProps.message.uri && 158 + prevProps.lane === nextProps.lane && 159 + prevProps.laneHeight === nextProps.laneHeight && 160 + prevProps.videoTop === nextProps.videoTop && 161 + prevProps.opacity === nextProps.opacity && 162 + prevProps.fontSize === nextProps.fontSize && 163 + prevProps.speed === nextProps.speed && 164 + prevProps.containerWidth === nextProps.containerWidth && 165 + prevProps.containerHeight === nextProps.containerHeight 166 + ); 167 + }, 168 + ); 169 + 170 + const styles = StyleSheet.create({ 171 + container: { 172 + position: "absolute", 173 + left: 0, 174 + }, 175 + text: { 176 + color: "white", 177 + textShadowColor: "rgba(0, 0, 0, 0.8)", 178 + textShadowOffset: { width: 1, height: 1 }, 179 + textShadowRadius: 64, 180 + fontWeight: "600", 181 + }, 182 + });
+44
js/components/src/components/danmu/danmu-overlay-obs.tsx
··· 1 + import { StyleSheet } from "react-native"; 2 + import { View } from "../ui"; 3 + import { DanmuOverlay } from "./danmu-overlay"; 4 + 5 + interface DanmuOverlayOBSProps { 6 + channelDid?: string; 7 + enabled?: boolean; 8 + opacity?: number; 9 + speed?: number; 10 + laneCount?: number; 11 + maxMessages?: number; 12 + } 13 + 14 + export function DanmuOverlayOBS({ 15 + channelDid, 16 + enabled = true, 17 + opacity = 80, 18 + speed = 1, 19 + laneCount = 12, 20 + maxMessages = 50, 21 + }: DanmuOverlayOBSProps) { 22 + return ( 23 + <View style={styles.container}> 24 + <DanmuOverlay 25 + enabled={enabled} 26 + opacity={opacity} 27 + speed={speed} 28 + laneCount={laneCount} 29 + maxMessages={maxMessages} 30 + /> 31 + </View> 32 + ); 33 + } 34 + 35 + const styles = StyleSheet.create({ 36 + container: { 37 + position: "absolute", 38 + top: 0, 39 + left: 0, 40 + right: 0, 41 + bottom: 0, 42 + backgroundColor: "transparent", 43 + }, 44 + });
+225
js/components/src/components/danmu/danmu-overlay.tsx
··· 1 + import { useCallback, useEffect, useRef, useState } from "react"; 2 + import { LayoutChangeEvent, StyleSheet } from "react-native"; 3 + import { ChatMessageViewHydrated } from "streamplace"; 4 + import { useChat, useLivestreamStore } from "../../livestream-store"; 5 + import { View } from "../ui"; 6 + import { DanmuMessage } from "./danmu-message"; 7 + import { baseDuration, MAX_DURATION, MIN_DURATION } from "./math"; 8 + import { useDanmuLanes } from "./use-danmu-lanes"; 9 + 10 + interface DanmuOverlayProps { 11 + enabled?: boolean; 12 + opacity?: number; 13 + speed?: number; 14 + laneCount?: number; 15 + maxMessages?: number; 16 + } 17 + 18 + interface ActiveDanmuMessage { 19 + message: ChatMessageViewHydrated; 20 + lane: number; 21 + } 22 + 23 + const DEFAULT_LANE_COUNT = 12; 24 + const DEFAULT_OPACITY = 80; 25 + const DEFAULT_SPEED = 1; 26 + const DEFAULT_MAX_MESSAGES = 50; 27 + const FONT_SIZE_PERCENTAGE = 0.7; 28 + const MAX_PROCESSED_MESSAGES = 10; 29 + 30 + // px from top of video where danmu won't appear (avoid overlapping with title) 31 + const TOP_GAP = 20; 32 + // px from bottom of video (avoid overlapping with controls) 33 + const BOTTOM_GAP = 20; 34 + 35 + export function DanmuOverlay({ 36 + enabled = true, 37 + opacity = DEFAULT_OPACITY, 38 + speed = DEFAULT_SPEED, 39 + laneCount = DEFAULT_LANE_COUNT, 40 + maxMessages = DEFAULT_MAX_MESSAGES, 41 + }: DanmuOverlayProps) { 42 + const chat = useChat(); 43 + const segment = useLivestreamStore((x) => x.segment); 44 + 45 + const [containerWidth, setContainerWidth] = useState(0); 46 + const [containerHeight, setContainerHeight] = useState(0); 47 + const [activeDanmu, setActiveDanmu] = useState< 48 + Map<string, ActiveDanmuMessage> 49 + >(new Map()); 50 + const processedMessages = useRef(new Set<string>()); 51 + const mountTime = useRef(Date.now()); 52 + const lastChatLength = useRef(0); 53 + const { assignLane, updateDanmuWidth, releaseLane, cleanup } = useDanmuLanes( 54 + laneCount, 55 + containerWidth, 56 + ); 57 + 58 + const handleLayout = useCallback((event: LayoutChangeEvent) => { 59 + const { width, height } = event.nativeEvent.layout; 60 + setContainerWidth(width); 61 + setContainerHeight(height); 62 + }, []); 63 + 64 + const handleMessageComplete = useCallback( 65 + (messageId: string) => { 66 + releaseLane(messageId); 67 + setActiveDanmu((prev) => { 68 + const next = new Map(prev); 69 + next.delete(messageId); 70 + return next; 71 + }); 72 + }, 73 + [releaseLane], 74 + ); 75 + 76 + const handleWidthMeasured = useCallback( 77 + (messageId: string, width: number) => { 78 + updateDanmuWidth(messageId, width); 79 + }, 80 + [updateDanmuWidth], 81 + ); 82 + 83 + useEffect(() => { 84 + if (!enabled || !chat || containerWidth === 0) return; 85 + 86 + // only check new messages since last render (chat is sorted newest first) 87 + const newMessageCount = chat.length - lastChatLength.current; 88 + if (newMessageCount <= 0) return; 89 + 90 + const messagesToCheck = chat.slice(0, newMessageCount); 91 + lastChatLength.current = chat.length; 92 + 93 + const newMessages = messagesToCheck.filter((msg) => { 94 + const hasProcessed = processedMessages.current.has(msg.uri); 95 + const isSystem = msg.author.did === "did:sys:system"; 96 + const msgTime = new Date(msg.record.createdAt).getTime(); 97 + const isAfterMount = msgTime >= mountTime.current; 98 + 99 + return !hasProcessed && !isSystem && isAfterMount; 100 + }); 101 + 102 + if (newMessages.length === 0) return; 103 + 104 + const messagesToAdd: ActiveDanmuMessage[] = []; 105 + 106 + for (const message of newMessages.slice(0, maxMessages)) { 107 + // mark as processed FIRST to prevent duplicate processing if effect runs twice 108 + if (processedMessages.current.has(message.uri)) { 109 + continue; 110 + } 111 + processedMessages.current.add(message.uri); 112 + 113 + if (processedMessages.current.size > MAX_PROCESSED_MESSAGES) { 114 + const toRemove = Array.from(processedMessages.current).slice( 115 + 0, 116 + processedMessages.current.size - MAX_PROCESSED_MESSAGES, 117 + ); 118 + toRemove.forEach((uri) => processedMessages.current.delete(uri)); 119 + } 120 + 121 + const duration = baseDuration(message, MAX_DURATION, MIN_DURATION); 122 + if (__DEV__) 123 + console.log("[danmu] message", message.record.text, { 124 + duration, 125 + speed, 126 + }); 127 + const lane = assignLane(message.uri, duration); 128 + 129 + if (lane !== null) { 130 + messagesToAdd.push({ message, lane }); 131 + } 132 + } 133 + 134 + if (messagesToAdd.length > 0) { 135 + setActiveDanmu((prev) => { 136 + const next = new Map(prev); 137 + for (const danmu of messagesToAdd) { 138 + next.set(danmu.message.uri, danmu); 139 + } 140 + return next; 141 + }); 142 + } 143 + }, [chat, enabled, speed, maxMessages]); 144 + 145 + useEffect(() => { 146 + const interval = setInterval(() => { 147 + cleanup(); 148 + }, 1000); 149 + 150 + return () => clearInterval(interval); 151 + }, [cleanup]); 152 + 153 + if (!enabled || containerWidth === 0 || containerHeight === 0) { 154 + return <View style={styles.container} onLayout={handleLayout} />; 155 + } 156 + 157 + // Calculate video bounds based on actual video dimensions from segment 158 + const segmentVideoWidth = segment?.video?.[0]?.width; 159 + const segmentVideoHeight = segment?.video?.[0]?.height; 160 + 161 + // Fall back to 16:9 if no segment dimensions available 162 + const videoAspectRatio = 163 + segmentVideoWidth && segmentVideoHeight 164 + ? segmentVideoWidth / segmentVideoHeight 165 + : 16 / 9; 166 + 167 + const containerAspectRatio = containerWidth / containerHeight; 168 + 169 + let videoWidth: number; 170 + let videoHeight: number; 171 + let videoTop: number; 172 + let videoLeft: number; 173 + 174 + if (containerAspectRatio > videoAspectRatio) { 175 + // Container is wider than video - letterbox on sides 176 + videoHeight = containerHeight; 177 + videoWidth = videoHeight * videoAspectRatio; 178 + videoTop = 0; 179 + videoLeft = (containerWidth - videoWidth) / 2; 180 + // Adjust for top/bottom gaps iff we don't have top letterboxing 181 + videoTop += TOP_GAP; 182 + videoHeight -= TOP_GAP + BOTTOM_GAP; 183 + } else { 184 + // Container is taller than video - letterbox on top/bottom 185 + videoWidth = containerWidth; 186 + videoHeight = videoWidth / videoAspectRatio; 187 + videoTop = (containerHeight - videoHeight) / 2; 188 + videoLeft = 0; 189 + } 190 + 191 + const laneHeight = videoHeight / laneCount; 192 + const fontSize = laneHeight * FONT_SIZE_PERCENTAGE; 193 + 194 + return ( 195 + <View style={styles.container} onLayout={handleLayout} pointerEvents="none"> 196 + {Array.from(activeDanmu.values()).map(({ message, lane }) => ( 197 + <DanmuMessage 198 + key={message.uri} 199 + message={message} 200 + lane={lane} 201 + laneHeight={laneHeight} 202 + videoTop={videoTop} 203 + opacity={opacity} 204 + fontSize={fontSize} 205 + speed={speed} 206 + containerWidth={containerWidth} 207 + containerHeight={containerHeight} 208 + onComplete={handleMessageComplete} 209 + onWidthMeasured={handleWidthMeasured} 210 + /> 211 + ))} 212 + </View> 213 + ); 214 + } 215 + 216 + const styles = StyleSheet.create({ 217 + container: { 218 + position: "absolute", 219 + top: 0, 220 + left: 0, 221 + right: 0, 222 + bottom: 0, 223 + overflow: "hidden", 224 + }, 225 + });
+27
js/components/src/components/danmu/math.ts
··· 1 + export const mapRange = ( 2 + num: number, 3 + inMin: number, 4 + inMax: number, 5 + outMin: number, 6 + outMax: number, 7 + ) => { 8 + return ((num - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; 9 + }; 10 + 11 + export const between = (num: number, min: number, max: number) => { 12 + return Math.min(Math.max(num, min), max); 13 + }; 14 + 15 + export const baseDuration = ( 16 + message: { record: { text: string | any[] } }, 17 + min: number, 18 + max: number, 19 + ) => 20 + between( 21 + mapRange(Math.log(message.record.text.length) * 8, 1, 16, min, max), 22 + min, 23 + max, 24 + ); 25 + 26 + export const MIN_DURATION = 6000; 27 + export const MAX_DURATION = 12000;
+28
js/components/src/components/danmu/mu.tsx
··· 1 + import { G, Path, Svg, SvgProps } from "react-native-svg"; 2 + 3 + export interface MuProps extends SvgProps { 4 + size?: number | string; 5 + color?: string; 6 + strokeWidth?: number | string; 7 + } 8 + 9 + export const Mu = ({ 10 + size = 24, 11 + color = "#000000", 12 + strokeWidth = 10.9, 13 + ...props 14 + }: MuProps) => ( 15 + <Svg width={size} height={size} viewBox="0 0 230 220" {...props}> 16 + <G 17 + strokeLinecap="round" 18 + fillRule="evenodd" 19 + stroke={color} 20 + strokeWidth={strokeWidth} 21 + fill={color} 22 + > 23 + <Path d="M 13.756 65.7 L 53.756 65.7 Q 55.756 65.7 57.256 64.2 Q 58.756 62.7 58.756 60.7 L 58.756 22.2 Q 58.756 20.2 57.256 18.7 Q 55.756 17.2 53.756 17.2 L 3.506 17.2 Q 2.006 17.2 1.006 16.2 Q 0.006 15.2 0.006 13.45 Q 0.006 11.95 1.006 10.95 Q 2.006 9.95 3.506 9.95 L 56.506 9.95 Q 60.756 9.95 63.506 12.7 Q 66.256 15.45 66.256 19.7 L 66.256 63.45 Q 66.256 67.45 63.506 70.325 Q 60.756 73.2 56.506 73.2 L 16.256 73.2 Q 14.006 73.2 12.506 74.7 Q 11.006 76.2 11.006 78.2 Q 10.756 89.7 10.506 98.575 Q 10.256 107.45 9.756 117.45 A 4.864 4.864 0 0 0 9.717 118.054 A 4.167 4.167 0 0 0 10.881 120.95 Q 12.256 122.45 14.506 122.45 L 60.256 122.45 Q 63.006 122.45 64.881 124.325 Q 66.756 126.2 66.756 129.7 A 1579.753 1579.753 0 0 1 65.295 155.188 Q 64.543 166.519 63.737 175.256 A 494.991 494.991 0 0 1 63.506 177.7 Q 61.756 195.7 59.506 204.7 A 39.785 39.785 0 0 1 55.486 215.384 A 14.939 14.939 0 0 1 54.006 217.45 Q 51.506 220.2 48.506 221.075 Q 45.506 221.95 40.506 222.2 Q 36.006 222.45 28.506 222.2 Q 21.006 221.95 11.756 221.45 A 5.389 5.389 0 0 1 10.79 221.197 A 5.13 5.13 0 0 1 9.131 220.2 Q 8.006 219.2 7.506 217.45 A 3.871 3.871 0 0 1 7.45 216.814 A 2.355 2.355 0 0 1 8.131 215.075 Q 9.006 214.2 10.506 214.2 Q 20.006 214.95 27.881 215.2 Q 35.756 215.45 39.006 215.45 Q 42.506 215.45 44.381 215.075 Q 46.256 214.7 47.756 212.95 A 17.691 17.691 0 0 0 51.389 205.661 A 55.6 55.6 0 0 0 52.381 201.825 A 144.371 144.371 0 0 0 54.411 189.832 A 374.249 374.249 0 0 0 55.881 176.95 A 735.769 735.769 0 0 0 57.475 157.825 A 1519.1 1519.1 0 0 0 59.006 133.45 A 5.968 5.968 0 0 0 59.061 132.663 A 2.91 2.91 0 0 0 55.506 129.7 L 11.506 129.7 Q 7.506 129.7 4.881 126.825 A 8.876 8.876 0 0 1 2.484 120.637 A 11.015 11.015 0 0 1 2.506 119.95 Q 2.756 113.95 3.006 108.7 Q 3.256 103.45 3.506 98.45 Q 3.756 93.45 3.881 87.95 Q 4.006 82.45 4.006 75.45 Q 4.006 71.2 6.881 68.45 Q 9.756 65.7 13.756 65.7 Z M 99.006 49.95 L 200.256 49.95 Q 204.506 49.95 207.256 52.7 Q 210.006 55.45 210.006 59.7 L 210.006 127.95 Q 210.006 131.95 207.256 134.825 Q 204.506 137.7 200.256 137.7 L 99.006 137.7 Q 95.006 137.7 92.131 134.825 Q 89.256 131.95 89.256 127.95 L 89.256 59.7 Q 89.256 55.45 92.131 52.7 Q 95.006 49.95 99.006 49.95 Z M 145.506 53.95 L 153.006 53.95 L 153.006 218.95 Q 153.006 220.7 152.006 221.7 Q 151.006 222.7 149.256 222.7 Q 147.506 222.7 146.506 221.7 Q 145.506 220.7 145.506 218.95 L 145.506 53.95 Z M 77.506 163.95 L 219.006 163.95 Q 220.756 163.95 221.756 164.95 Q 222.756 165.95 222.756 167.7 Q 222.756 169.45 221.756 170.45 Q 220.756 171.45 219.006 171.45 L 77.506 171.45 Q 75.756 171.45 74.756 170.45 Q 73.756 169.45 73.756 167.7 Q 73.756 165.95 74.756 164.95 Q 75.756 163.95 77.506 163.95 Z M 96.506 97.45 L 96.506 125.45 Q 96.506 127.45 98.006 128.95 Q 99.506 130.45 101.506 130.45 L 197.506 130.45 Q 199.506 130.45 201.006 128.95 Q 202.506 127.45 202.506 125.45 L 202.506 97.45 L 96.506 97.45 Z M 96.506 62.45 L 96.506 89.95 L 202.506 89.95 L 202.506 62.45 Q 202.506 60.45 201.006 58.95 Q 199.506 57.45 197.506 57.45 L 101.506 57.45 Q 99.506 57.45 98.006 58.95 Q 96.506 60.45 96.506 62.45 Z M 166.506 55.2 L 161.006 51.95 Q 168.756 41.7 176.881 27.7 A 383.321 383.321 0 0 0 189.09 5.114 A 264.812 264.812 0 0 0 190.506 2.2 Q 191.006 0.7 192.506 0.2 Q 194.006 -0.3 195.756 0.45 Q 197.256 0.95 197.756 2.45 Q 198.256 3.95 197.506 5.2 Q 190.506 17.95 182.506 31.45 Q 174.506 44.95 166.506 55.2 Z M 107.672 1.117 A 4.458 4.458 0 0 0 106.006 1.45 Q 104.756 1.95 104.381 2.95 Q 104.006 3.95 104.756 4.95 Q 111.756 13.95 117.006 22.825 Q 122.256 31.7 126.006 39.95 Q 126.506 41.2 127.756 41.7 Q 129.006 42.2 130.256 41.45 Q 132.006 40.7 132.506 39.2 Q 133.006 37.7 132.256 36.45 Q 128.506 28.45 123.256 19.95 Q 118.006 11.45 110.756 2.45 Q 109.756 1.45 108.506 1.2 A 4.24 4.24 0 0 0 107.672 1.117 Z" /> 24 + </G> 25 + </Svg> 26 + ); 27 + 28 + export default Mu;
+114
js/components/src/components/danmu/use-danmu-lanes.ts
··· 1 + import { useCallback, useRef } from "react"; 2 + 3 + export interface DanmuLane { 4 + index: number; 5 + occupiedUntil: number; 6 + } 7 + 8 + export interface ActiveDanmu { 9 + id: string; 10 + lane: number; 11 + endTime: number; 12 + startTime: number; 13 + duration: number; 14 + width?: number; 15 + } 16 + 17 + const LANE_GAP = 4; 18 + 19 + export const useDanmuLanes = (laneCount: number, containerWidth: number) => { 20 + const activeDanmu = useRef<Map<string, ActiveDanmu>>(new Map()); 21 + const lanes = useRef<DanmuLane[]>( 22 + Array.from({ length: laneCount }, (_, i) => ({ 23 + index: i, 24 + occupiedUntil: 0, 25 + })), 26 + ); 27 + 28 + const canFitInLane = useCallback( 29 + (laneIndex: number): boolean => { 30 + const now = Date.now(); 31 + 32 + // find all active danmu in this lane 33 + const danmuInLane = Array.from(activeDanmu.current.values()).filter( 34 + (d) => d.lane === laneIndex && d.endTime > now, 35 + ); 36 + 37 + if (danmuInLane.length === 0) return true; 38 + 39 + // check the most recent danmu in this lane 40 + const mostRecent = danmuInLane.reduce((latest, current) => 41 + current.startTime > latest.startTime ? current : latest, 42 + ); 43 + 44 + // calculate how far it has traveled 45 + const elapsed = now - mostRecent.startTime; 46 + const progress = elapsed / mostRecent.duration; 47 + const traveled = containerWidth * progress; 48 + 49 + // estimate width (assume ~8px per char, will be updated with actual width later) 50 + const estimatedWidth = mostRecent.width || 200; 51 + 52 + // check if there's enough space 53 + const spaceNeeded = estimatedWidth + LANE_GAP; 54 + const hasSpace = traveled >= spaceNeeded; 55 + 56 + return hasSpace; 57 + }, 58 + [containerWidth], 59 + ); 60 + 61 + const assignLane = useCallback( 62 + (messageId: string, duration: number, width?: number): number | null => { 63 + const now = Date.now(); 64 + 65 + for (const lane of lanes.current) { 66 + if (canFitInLane(lane.index)) { 67 + const endTime = now + duration; 68 + activeDanmu.current.set(messageId, { 69 + id: messageId, 70 + lane: lane.index, 71 + endTime, 72 + startTime: now, 73 + duration, 74 + width, 75 + }); 76 + return lane.index; 77 + } 78 + } 79 + 80 + return null; 81 + }, 82 + [lanes, canFitInLane], 83 + ); 84 + 85 + const updateDanmuWidth = useCallback((messageId: string, width: number) => { 86 + const danmu = activeDanmu.current.get(messageId); 87 + if (danmu) { 88 + danmu.width = width; 89 + } 90 + }, []); 91 + 92 + const releaseLane = useCallback((messageId: string) => { 93 + const danmu = activeDanmu.current.get(messageId); 94 + if (danmu) { 95 + activeDanmu.current.delete(messageId); 96 + } 97 + }, []); 98 + 99 + const cleanup = useCallback(() => { 100 + const now = Date.now(); 101 + for (const [id, danmu] of activeDanmu.current.entries()) { 102 + if (danmu.endTime <= now) { 103 + activeDanmu.current.delete(id); 104 + } 105 + } 106 + }, []); 107 + 108 + return { 109 + assignLane, 110 + updateDanmuWidth, 111 + releaseLane, 112 + cleanup, 113 + }; 114 + };
+26
js/components/src/components/mobile-player/fullscreen.native.tsx
··· 4 4 import { SystemBars } from "react-native-edge-to-edge"; 5 5 import { useSafeAreaInsets } from "react-native-safe-area-context"; 6 6 import { 7 + DanmuOverlay, 7 8 PlayerProtocol, 9 + useDanmuEnabled, 10 + useDanmuLaneCount, 11 + useDanmuMaxMessages, 12 + useDanmuOpacity, 13 + useDanmuSpeed, 8 14 useLivestreamStore, 9 15 usePlayerStore, 10 16 VideoRetry, ··· 29 35 const fullscreen = usePlayerStore((x) => x.fullscreen); 30 36 const setFullscreen = usePlayerStore((x) => x.setFullscreen); 31 37 const handle = useLivestreamStore((x) => x.profile?.handle); 38 + 39 + const danmuEnabled = useDanmuEnabled(); 40 + const danmuOpacity = useDanmuOpacity(); 41 + const danmuSpeed = useDanmuSpeed(); 42 + const danmuLaneCount = useDanmuLaneCount(); 43 + const danmuMaxMessages = useDanmuMaxMessages(); 32 44 33 45 const setSrc = usePlayerStore((x) => x.setSrc); 34 46 ··· 154 166 objectFit={props.objectFit} 155 167 pictureInPictureEnabled={props.pictureInPictureEnabled} 156 168 /> 169 + <DanmuOverlay 170 + enabled={danmuEnabled} 171 + opacity={danmuOpacity} 172 + speed={danmuSpeed} 173 + laneCount={danmuLaneCount} 174 + maxMessages={danmuMaxMessages} 175 + /> 157 176 {props.children} 158 177 </View> 159 178 </View> ··· 169 188 pictureInPictureEnabled={props.pictureInPictureEnabled} 170 189 /> 171 190 </VideoRetry> 191 + <DanmuOverlay 192 + enabled={danmuEnabled} 193 + opacity={danmuOpacity} 194 + speed={danmuSpeed} 195 + laneCount={danmuLaneCount} 196 + maxMessages={danmuMaxMessages} 197 + /> 172 198 {props.children} 173 199 </> 174 200 );
+23 -1
js/components/src/components/mobile-player/fullscreen.tsx
··· 1 1 import { useEffect, useRef } from "react"; 2 2 import { View as RNView } from "react-native"; 3 - import { getFirstPlayerID, usePlayerStore } from "../.."; 3 + import { 4 + DanmuOverlay, 5 + getFirstPlayerID, 6 + useDanmuEnabled, 7 + useDanmuLaneCount, 8 + useDanmuMaxMessages, 9 + useDanmuOpacity, 10 + useDanmuSpeed, 11 + usePlayerStore, 12 + } from "../.."; 4 13 import { View } from "../../components/ui"; 5 14 import Video from "./video"; 6 15 import VideoRetry from "./video-retry"; ··· 17 26 const setFullscreen = usePlayerStore((x) => x.setFullscreen, playerId); 18 27 const setSrc = usePlayerStore((x) => x.setSrc); 19 28 const setAutoplayFailed = usePlayerStore((x) => x.setAutoplayFailed); 29 + 30 + const danmuEnabled = useDanmuEnabled(); 31 + const danmuOpacity = useDanmuOpacity(); 32 + const danmuSpeed = useDanmuSpeed(); 33 + const danmuLaneCount = useDanmuLaneCount(); 34 + const danmuMaxMessages = useDanmuMaxMessages(); 20 35 21 36 const divRef = useRef<RNView>(null); 22 37 const videoRef = useRef<HTMLVideoElement | null>(null); ··· 90 105 pictureInPictureEnabled={props.pictureInPictureEnabled} 91 106 /> 92 107 </VideoRetry> 108 + <DanmuOverlay 109 + enabled={danmuEnabled} 110 + opacity={danmuOpacity} 111 + speed={danmuSpeed} 112 + laneCount={danmuLaneCount} 113 + maxMessages={danmuMaxMessages} 114 + /> 93 115 {props.children} 94 116 </View> 95 117 );
+3
js/components/src/index.tsx
··· 37 37 export { default as VideoRetry } from "./components/mobile-player/video-retry"; 38 38 export * from "./lib/system-messages"; 39 39 40 + export { DanmuOverlay } from "./components/danmu/danmu-overlay"; 41 + export { DanmuOverlayOBS } from "./components/danmu/danmu-overlay-obs"; 42 + 40 43 // Rotation lock system exports 41 44 export { 42 45 RotationProvider,
+184 -2
js/components/src/streamplace-store/streamplace-store.tsx
··· 54 54 muted: boolean; 55 55 setVolume: (volume: number) => void; 56 56 setMuted: (muted: boolean) => void; 57 + 58 + // Danmu settings 59 + danmuUnlocked: boolean; 60 + danmuEnabled: boolean; 61 + danmuOpacity: number; 62 + danmuSpeed: number; 63 + danmuLaneCount: number; 64 + danmuMaxMessages: number; 65 + setDanmuUnlocked: (unlocked: boolean) => void; 66 + setDanmuEnabled: (enabled: boolean) => void; 67 + setDanmuOpacity: (opacity: number) => void; 68 + setDanmuSpeed: (speed: number) => void; 69 + setDanmuLaneCount: (laneCount: number) => void; 70 + setDanmuMaxMessages: (maxMessages: number) => void; 57 71 } 58 72 59 73 export type StreamplaceStore = StoreApi<StreamplaceState>; ··· 65 79 }): StoreApi<StreamplaceState> => { 66 80 const VOLUME_STORAGE_KEY = "globalVolume"; 67 81 const MUTED_STORAGE_KEY = "globalMuted"; 82 + const DANMU_UNLOCKED_KEY = "danmuUnlocked"; 83 + const DANMU_ENABLED_KEY = "danmuEnabled"; 84 + const DANMU_OPACITY_KEY = "danmuOpacity"; 85 + const DANMU_SPEED_KEY = "danmuSpeed"; 86 + const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; 87 + const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages"; 68 88 69 89 const store = createStore<StreamplaceState>()((set) => ({ 70 90 url, ··· 125 145 set({ muted }); 126 146 storage.setItem(MUTED_STORAGE_KEY, muted.toString()).catch(console.error); 127 147 }, 148 + 149 + // Danmu settings - start with defaults 150 + danmuUnlocked: false, 151 + danmuEnabled: false, 152 + danmuOpacity: 80, 153 + danmuSpeed: 1, 154 + danmuLaneCount: 12, 155 + danmuMaxMessages: 50, 156 + 157 + setDanmuUnlocked: (unlocked: boolean) => { 158 + set({ danmuUnlocked: unlocked }); 159 + storage 160 + .setItem(DANMU_UNLOCKED_KEY, unlocked.toString()) 161 + .catch(console.error); 162 + }, 163 + 164 + setDanmuEnabled: (enabled: boolean) => { 165 + set({ danmuEnabled: enabled }); 166 + storage 167 + .setItem(DANMU_ENABLED_KEY, enabled.toString()) 168 + .catch(console.error); 169 + }, 170 + 171 + setDanmuOpacity: (opacity: number) => { 172 + const clamped = Math.max(0, Math.min(100, opacity)); 173 + set({ danmuOpacity: clamped }); 174 + storage 175 + .setItem(DANMU_OPACITY_KEY, clamped.toString()) 176 + .catch(console.error); 177 + }, 178 + 179 + setDanmuSpeed: (speed: number) => { 180 + const clamped = Math.max(0.1, Math.min(3, speed)); 181 + set({ danmuSpeed: clamped }); 182 + storage.setItem(DANMU_SPEED_KEY, clamped.toString()).catch(console.error); 183 + }, 184 + 185 + setDanmuLaneCount: (laneCount: number) => { 186 + const clamped = Math.max(4, Math.min(20, laneCount)); 187 + set({ danmuLaneCount: clamped }); 188 + storage 189 + .setItem(DANMU_LANE_COUNT_KEY, clamped.toString()) 190 + .catch(console.error); 191 + }, 192 + 193 + setDanmuMaxMessages: (maxMessages: number) => { 194 + const clamped = Math.max(5, Math.min(200, maxMessages)); 195 + set({ danmuMaxMessages: clamped }); 196 + storage 197 + .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) 198 + .catch(console.error); 199 + }, 128 200 })); 129 201 130 - // Load initial volume state from storage asynchronously 202 + // Load initial volume and danmu state from storage asynchronously 131 203 (async () => { 132 204 try { 133 205 const storedVolume = await storage.getItem(VOLUME_STORAGE_KEY); 134 206 const storedMuted = await storage.getItem(MUTED_STORAGE_KEY); 207 + const storedDanmuUnlocked = await storage.getItem(DANMU_UNLOCKED_KEY); 208 + const storedDanmuEnabled = await storage.getItem(DANMU_ENABLED_KEY); 209 + const storedDanmuOpacity = await storage.getItem(DANMU_OPACITY_KEY); 210 + const storedDanmuSpeed = await storage.getItem(DANMU_SPEED_KEY); 211 + const storedDanmuLaneCount = await storage.getItem(DANMU_LANE_COUNT_KEY); 212 + const storedDanmuMaxMessages = await storage.getItem( 213 + DANMU_MAX_MESSAGES_KEY, 214 + ); 135 215 136 216 let initialVolume = 1.0; 137 217 let initialMuted = false; 218 + let initialDanmuUnlocked = false; 219 + let initialDanmuEnabled = false; 220 + let initialDanmuOpacity = 80; 221 + let initialDanmuSpeed = 1; 222 + let initialDanmuLaneCount = 12; 223 + let initialDanmuMaxMessages = 50; 138 224 139 225 if (storedVolume) { 140 226 const parsedVolume = parseFloat(storedVolume); ··· 151 237 initialMuted = storedMuted === "true"; 152 238 } 153 239 240 + if (storedDanmuUnlocked) { 241 + initialDanmuUnlocked = storedDanmuUnlocked === "true"; 242 + } 243 + 244 + if (storedDanmuEnabled) { 245 + initialDanmuEnabled = storedDanmuEnabled === "true"; 246 + } 247 + 248 + if (storedDanmuOpacity) { 249 + const parsed = parseInt(storedDanmuOpacity); 250 + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) { 251 + initialDanmuOpacity = parsed; 252 + } 253 + } 254 + 255 + if (storedDanmuSpeed) { 256 + const parsed = parseFloat(storedDanmuSpeed); 257 + if (Number.isFinite(parsed) && parsed >= 0.1 && parsed <= 3) { 258 + initialDanmuSpeed = parsed; 259 + } 260 + } 261 + 262 + if (storedDanmuLaneCount) { 263 + const parsed = parseInt(storedDanmuLaneCount); 264 + if (Number.isFinite(parsed) && parsed >= 4 && parsed <= 20) { 265 + initialDanmuLaneCount = parsed; 266 + } 267 + } 268 + 269 + if (storedDanmuMaxMessages) { 270 + const parsed = parseInt(storedDanmuMaxMessages); 271 + if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 200) { 272 + initialDanmuMaxMessages = parsed; 273 + } 274 + } 275 + 154 276 store.setState({ 155 277 volume: initialVolume, 156 278 muted: initialMuted, 279 + danmuUnlocked: initialDanmuUnlocked, 280 + danmuEnabled: initialDanmuEnabled, 281 + danmuOpacity: initialDanmuOpacity, 282 + danmuSpeed: initialDanmuSpeed, 283 + danmuLaneCount: initialDanmuLaneCount, 284 + danmuMaxMessages: initialDanmuMaxMessages, 157 285 }); 158 286 } catch (error) { 159 - console.error("Failed to load volume state from storage:", error); 287 + console.error("Failed to load state from storage:", error); 160 288 } 161 289 })(); 162 290 ··· 212 340 // Ensure we always return a finite number for HTMLMediaElement.volume 213 341 return Number.isFinite(effectiveVolume) ? effectiveVolume : 1.0; 214 342 }); 343 + 344 + // Danmu convenience hooks 345 + export const useDanmuUnlocked = () => 346 + useStreamplaceStore((x) => x.danmuUnlocked); 347 + export const useDanmuEnabled = () => useStreamplaceStore((x) => x.danmuEnabled); 348 + export const useDanmuOpacity = () => useStreamplaceStore((x) => x.danmuOpacity); 349 + export const useDanmuSpeed = () => useStreamplaceStore((x) => x.danmuSpeed); 350 + export const useDanmuLaneCount = () => 351 + useStreamplaceStore((x) => x.danmuLaneCount); 352 + export const useDanmuMaxMessages = () => 353 + useStreamplaceStore((x) => x.danmuMaxMessages); 354 + export const useSetDanmuUnlocked = () => 355 + useStreamplaceStore((x) => x.setDanmuUnlocked); 356 + export const useSetDanmuEnabled = () => 357 + useStreamplaceStore((x) => x.setDanmuEnabled); 358 + export const useSetDanmuOpacity = () => 359 + useStreamplaceStore((x) => x.setDanmuOpacity); 360 + export const useSetDanmuSpeed = () => 361 + useStreamplaceStore((x) => x.setDanmuSpeed); 362 + export const useSetDanmuLaneCount = () => 363 + useStreamplaceStore((x) => x.setDanmuLaneCount); 364 + export const useSetDanmuMaxMessages = () => 365 + useStreamplaceStore((x) => x.setDanmuMaxMessages); 366 + 367 + // Composite hook that calls all individual hooks 368 + export const useDanmuSettings = () => { 369 + const danmuUnlocked = useDanmuUnlocked(); 370 + const danmuEnabled = useDanmuEnabled(); 371 + const danmuOpacity = useDanmuOpacity(); 372 + const danmuSpeed = useDanmuSpeed(); 373 + const danmuLaneCount = useDanmuLaneCount(); 374 + const danmuMaxMessages = useDanmuMaxMessages(); 375 + const setDanmuUnlocked = useSetDanmuUnlocked(); 376 + const setDanmuEnabled = useSetDanmuEnabled(); 377 + const setDanmuOpacity = useSetDanmuOpacity(); 378 + const setDanmuSpeed = useSetDanmuSpeed(); 379 + const setDanmuLaneCount = useSetDanmuLaneCount(); 380 + const setDanmuMaxMessages = useSetDanmuMaxMessages(); 381 + 382 + return { 383 + danmuUnlocked, 384 + danmuEnabled, 385 + danmuOpacity, 386 + danmuSpeed, 387 + danmuLaneCount, 388 + danmuMaxMessages, 389 + setDanmuUnlocked, 390 + setDanmuEnabled, 391 + setDanmuOpacity, 392 + setDanmuSpeed, 393 + setDanmuLaneCount, 394 + setDanmuMaxMessages, 395 + }; 396 + }; 215 397 216 398 export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream";
+4 -4
js/docs/astro.config.mjs
··· 66 66 }, 67 67 ], 68 68 }, 69 - // { 70 - // label: "Reference", 71 - // autogenerate: { directory: "reference" }, 72 - // }, 69 + { 70 + label: "Features", 71 + autogenerate: { directory: "features" }, 72 + }, 73 73 { 74 74 label: "Video Metadata", 75 75 autogenerate: { directory: "video-metadata" },
+62
js/docs/src/content/docs/features/danmu.md
··· 1 + --- 2 + title: Danmu Overlay 3 + description: Add flying bullet-style chat comments to the player, or your stream 4 + --- 5 + 6 + [Danmu (or Danmaku)](https://en.wikipedia.org/wiki/Danmaku_subtitling) (弹幕, 7 + "bullet curtain") is a comment style where messages fly across the video 8 + horizontally. Originating from Niconico and Bilibili, it's a fun way to display 9 + chat that feels more integrated with the content. Use it in your live streams to 10 + create a more engaging viewer experience. 11 + 12 + ## What It Does 13 + 14 + Displays chat messages as animated text that scrolls across your stream. 15 + Messages appear in lanes and move right-to-left at configurable speeds. The 16 + overlay is transparent so you can layer it over your video in OBS. 17 + 18 + ## Enabling Danmu in the player 19 + 20 + In-player danmu is currently an experimental feature. To unlock it: 21 + 22 + 1. Open Settings in Streamplace 23 + 2. Tap the version number 5 times 24 + 3. You'll see "You are now a developer". congrats! 25 + 4. Scroll down to see the Danmu settings 26 + 27 + From there you can: 28 + 29 + - Toggle Danmu on/off 30 + - Adjust opacity (0–100%) 31 + - Change scroll speed (0.5× to 2×) 32 + - Set number of lanes (4–20) 33 + - Limit max simultaneous messages (5–200) 34 + 35 + You can then enable Danmu in the player by clicking the Danmu (弹) icon in the 36 + bottom right controls row. 37 + 38 + ## Using It in OBS 39 + 40 + The Danmu overlay can be used as a browser source in OBS: 41 + 42 + 1. Add a Browser Source to your scene 43 + 2. Set the URL to `https://stream.place/widgets/USERNAME/danmu` 44 + - Replace `USERNAME` with your Bluesky handle (without the @) 45 + 3. Set width/height to match your canvas (e.g., 1920×1080) 46 + 4. Check "Shutdown source when not visible" 47 + 5. Optionally check "Refresh browser when scene becomes active" 48 + 49 + ### Customization via URL Parameters 50 + 51 + You can customize the overlay by adding URL parameters: 52 + 53 + - `opacity` — transparency (0–100, default 80) 54 + - `speed` — scroll speed multiplier (default 1) 55 + - `laneCount` — number of message lanes (default 12) 56 + - `maxMessages` — max simultaneous messages (default 50) 57 + 58 + Example with custom settings: 59 + 60 + ``` 61 + https://stream.place/widgets/USERNAME/danmu?opacity=90&speed=1.5&laneCount=10 62 + ```