fullscreen-toggle.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. 'use client'
  2. import { useCallback, useEffect, useState } from 'react'
  3. import { useTranslations } from 'next-intl'
  4. import { Maximize, Minimize } from 'lucide-react'
  5. import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
  6. import { useInstallPrompt } from '@/components/pwa-install-prompt'
  7. /**
  8. * Kept in localStorage, not in workshop settings, unlike the feature hints.
  9. *
  10. * This one is genuinely per-device. The desk machine has a keyboard and other
  11. * windows to switch between; the tablet in the bay is held in one hand and
  12. * wants every pixel. The same person wants opposite answers on each, so a
  13. * workshop-wide setting would be wrong on one of them.
  14. */
  15. const PREF_KEY = 'app-fullscreen'
  16. /**
  17. * A refused fullscreen request is not worth reporting.
  18. *
  19. * The browser refuses for reasons the person cannot act on, such as a
  20. * permissions policy on an embedded install, and the button not visibly doing
  21. * anything says everything a toast would. The button's own state resyncs from
  22. * the fullscreenchange event either way.
  23. */
  24. function ignoreRefusal() {
  25. return undefined
  26. }
  27. function prefersFullscreen(): boolean {
  28. try {
  29. return localStorage.getItem(PREF_KEY) === 'true'
  30. } catch {
  31. return false
  32. }
  33. }
  34. /** Whether the browser offers fullscreen at all, and whether it is on now. */
  35. function useFullscreenState() {
  36. const [supported, setSupported] = useState(false)
  37. const [active, setActive] = useState(false)
  38. useEffect(() => {
  39. setSupported(typeof document !== 'undefined' && document.fullscreenEnabled)
  40. const sync = () => setActive(document.fullscreenElement !== null)
  41. sync()
  42. document.addEventListener('fullscreenchange', sync)
  43. return () => document.removeEventListener('fullscreenchange', sync)
  44. }, [])
  45. return { supported, active }
  46. }
  47. /**
  48. * Fullscreen for the installed app.
  49. *
  50. * An installed web app cannot ask to launch fullscreen on a desktop: Chrome
  51. * and Edge honour the manifest's fullscreen display mode on Android and
  52. * ChromeOS, and fall back to a plain window everywhere else. The only route on
  53. * a desktop is the Fullscreen API, which needs a user gesture and so cannot
  54. * fire on load.
  55. *
  56. * So the preference is remembered and spent on the first gesture after launch:
  57. * open the app, click anything, and it fills the screen. That is as close to
  58. * "launches fullscreen" as the platform allows, and it stays honest about
  59. * needing the click rather than failing silently on load.
  60. *
  61. * Only ever automatic for the installed app. Doing this to a browser tab would
  62. * hijack the first click on a page somebody opened alongside others.
  63. *
  64. * Renders nothing. It lives apart from the menu item because that item sits
  65. * in a dropdown that is not mounted until somebody opens it, and the first
  66. * gesture after launch has to be caught before then.
  67. */
  68. export function FullscreenLauncher() {
  69. const { installed } = useInstallPrompt()
  70. const { supported } = useFullscreenState()
  71. // Spend the remembered preference on the first click or keypress. Listening
  72. // once, in capture, so the gesture still reaches whatever was clicked.
  73. useEffect(() => {
  74. if (!installed || !supported) return
  75. if (!prefersFullscreen()) return
  76. if (document.fullscreenElement) return
  77. const enter = () => {
  78. void document.documentElement.requestFullscreen().catch(ignoreRefusal)
  79. }
  80. document.addEventListener('pointerdown', enter, { once: true, capture: true })
  81. document.addEventListener('keydown', enter, { once: true, capture: true })
  82. return () => {
  83. document.removeEventListener('pointerdown', enter, { capture: true })
  84. document.removeEventListener('keydown', enter, { capture: true })
  85. }
  86. }, [installed, supported])
  87. return null
  88. }
  89. /** The fullscreen switch, as an entry in the account menu. */
  90. export function FullscreenMenuItem() {
  91. const t = useTranslations('common.shared')
  92. const { supported, active } = useFullscreenState()
  93. const toggle = useCallback(() => {
  94. if (document.fullscreenElement) {
  95. // Turning it off is also a decision: the next launch should respect it.
  96. try {
  97. localStorage.setItem(PREF_KEY, 'false')
  98. } catch {
  99. // Private mode, or storage disabled. The toggle still works for now.
  100. }
  101. void document.exitFullscreen().catch(ignoreRefusal)
  102. return
  103. }
  104. try {
  105. localStorage.setItem(PREF_KEY, 'true')
  106. } catch {
  107. // As above.
  108. }
  109. void document.documentElement.requestFullscreen().catch(ignoreRefusal)
  110. }, [])
  111. if (!supported) return null
  112. return (
  113. <DropdownMenuItem onClick={toggle}>
  114. {active ? <Minimize className="mr-2 size-4" /> : <Maximize className="mr-2 size-4" />}
  115. {active ? t('exitFullscreen') : t('enterFullscreen')}
  116. </DropdownMenuItem>
  117. )
  118. }