lzc-video-player 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.dockerignore +1 -0
  2. package/.eslintrc.cjs +18 -0
  3. package/.prettierrc.json +5 -0
  4. package/AGENTS.md +31 -0
  5. package/README.md +38 -0
  6. package/build.sh +10 -0
  7. package/demo/.vscode/extensions.json +3 -0
  8. package/demo/README.md +40 -0
  9. package/demo/env.d.ts +1 -0
  10. package/demo/index.html +13 -0
  11. package/demo/package-lock.json +2037 -0
  12. package/demo/package.json +25 -0
  13. package/demo/public/favicon.ico +0 -0
  14. package/demo/src/App.vue +25 -0
  15. package/demo/src/assets/base.css +70 -0
  16. package/demo/src/assets/logo.svg +1 -0
  17. package/demo/src/assets/main.css +33 -0
  18. package/demo/src/main.ts +8 -0
  19. package/demo/tsconfig.config.json +8 -0
  20. package/demo/tsconfig.json +16 -0
  21. package/demo/vite.config.ts +14 -0
  22. package/docs/progress-bar-style-analysis.md +87 -0
  23. package/env.d.ts +1 -0
  24. package/error_pages/502.html.tpl +13 -0
  25. package/i18next-parser.config.mjs +147 -0
  26. package/index.html +54 -0
  27. package/lazycat.png +0 -0
  28. package/lib/README.md +48 -0
  29. package/lib/package.json +22 -0
  30. package/lzc-build.local.yml +65 -0
  31. package/lzc-build.yml +65 -0
  32. package/lzc-manifest.yml +53 -0
  33. package/makefile +15 -0
  34. package/package.json +69 -0
  35. package/postcss.config.js +6 -0
  36. package/public/512x512.png +0 -0
  37. package/public/favicon.ico +0 -0
  38. package/public/languages/en/translation.json +125 -0
  39. package/public/languages/zh/translation.json +125 -0
  40. package/public/libass-wasm/4.1.0/default.woff2 +0 -0
  41. package/public/libass-wasm/4.1.0/subtitles-octopus-worker-legacy.js +40 -0
  42. package/public/libass-wasm/4.1.0/subtitles-octopus-worker.js +1 -0
  43. package/public/libass-wasm/4.1.0/subtitles-octopus-worker.wasm +0 -0
  44. package/public/libass-wasm/4.1.0/subtitles-octopus.js +1680 -0
  45. package/public/square-128x128.png +0 -0
  46. package/public/square-256x256.png +0 -0
  47. package/public/square-512x512.png +0 -0
  48. package/src/App.vue +18 -0
  49. package/src/assets/base.scss +104 -0
  50. package/src/assets/cloud.png +0 -0
  51. package/src/assets/logo.svg +1 -0
  52. package/src/components/Dialog/index.vue +96 -0
  53. package/src/components/MultipleEdit/choose.vue +39 -0
  54. package/src/components/PlayList/index.vue +521 -0
  55. package/src/components/Spectrum/index.vue +58 -0
  56. package/src/components/Video/NativeVideoPlayer.vue +748 -0
  57. package/src/components/Video/README.md +3 -0
  58. package/src/components/Video/clientPlayer.ts +348 -0
  59. package/src/components/Video/components/LzcModal/components/simpleList.vue +57 -0
  60. package/src/components/Video/components/LzcModal/list.vue +52 -0
  61. package/src/components/Video/components/LzcModal/playrate.vue +45 -0
  62. package/src/components/Video/components/LzcModal/resolution.vue +117 -0
  63. package/src/components/Video/components/LzcModal/subtitle.vue +499 -0
  64. package/src/components/Video/components/LzcModal/useModal.ts +18 -0
  65. package/src/components/Video/components/LzcOverlay/SubtitleLayer.vue +321 -0
  66. package/src/components/Video/components/LzcOverlay/cast.vue +253 -0
  67. package/src/components/Video/components/LzcOverlay/casting.vue +205 -0
  68. package/src/components/Video/components/LzcOverlay/error.vue +103 -0
  69. package/src/components/Video/components/LzcOverlay/helper.ts +81 -0
  70. package/src/components/Video/components/LzcOverlay/index.vue +99 -0
  71. package/src/components/Video/components/LzcOverlay/playing.vue +496 -0
  72. package/src/components/Video/components/LzcOverlay/playingButtons.vue +122 -0
  73. package/src/components/Video/components/LzcOverlay/playingLayout.vue +287 -0
  74. package/src/components/Video/components/LzcOverlay/useCast.ts +235 -0
  75. package/src/components/Video/components/LzcOverlay/useCommon.ts +41 -0
  76. package/src/components/Video/components/LzcOverlay/useOctopusRenderer.ts +230 -0
  77. package/src/components/Video/components/LzcOverlay/useSubtitleRenderEngine.ts +79 -0
  78. package/src/components/Video/components/LzcOverlay/useSubtitleTrack.ts +139 -0
  79. package/src/components/Video/components/useLzcCommon.ts +16 -0
  80. package/src/components/Video/directPlay.ts +345 -0
  81. package/src/components/Video/getSubtitleInfo.ts +42 -0
  82. package/src/components/Video/native/EventEmitter.ts +62 -0
  83. package/src/components/Video/native/NativeControls.vue +510 -0
  84. package/src/components/Video/native/NativeModal.vue +133 -0
  85. package/src/components/Video/native/NativePlayer.ts +913 -0
  86. package/src/components/Video/native/NativePlayer.vue +53 -0
  87. package/src/components/Video/native/index.ts +9 -0
  88. package/src/components/Video/native/native-player.css +183 -0
  89. package/src/components/Video/native/playerKey.ts +5 -0
  90. package/src/components/Video/native/useNativeCastMiddleware.ts +50 -0
  91. package/src/components/Video/native/useNativePlayer.ts +3 -0
  92. package/src/components/Video/native/useNativePlayerFullscreen.ts +44 -0
  93. package/src/components/Video/native/useNativePlayerHistory.ts +69 -0
  94. package/src/components/Video/native/useNativePlayerModal.ts +68 -0
  95. package/src/components/Video/native/useNativePlayerPlaylist.ts +67 -0
  96. package/src/components/Video/native/useNativePlayerState.ts +225 -0
  97. package/src/components/Video/player.ts +99 -0
  98. package/src/components/Video/theme/index.scss +291 -0
  99. package/src/components/Video/theme/videojs.css +1797 -0
  100. package/src/components/Video/useSource.ts +1431 -0
  101. package/src/components/Video/useSubtitlePreference.ts +66 -0
  102. package/src/components/Video/useWebview.ts +79 -0
  103. package/src/components/Video/videoFrame.ts +58 -0
  104. package/src/env.d.ts +3 -0
  105. package/src/i18n/README.md +392 -0
  106. package/src/i18n/index.ts +49 -0
  107. package/src/icons/Video_Player.svg +69 -0
  108. package/src/icons/box.svg +15 -0
  109. package/src/icons/client.svg +17 -0
  110. package/src/icons/logo.svg +28 -0
  111. package/src/icons//344/270/212/344/270/200/344/270/252.svg +6 -0
  112. package/src/icons//344/270/213/344/270/200/344/270/252.svg +4 -0
  113. package/src/icons//344/272/256/345/272/246.svg +13 -0
  114. package/src/icons//345/200/215/351/200/237.svg +14 -0
  115. package/src/icons//345/205/250/345/261/217.svg +16 -0
  116. package/src/icons//345/205/250/351/200/211_/345/267/262/351/200/211/344/270/255.svg +16 -0
  117. package/src/icons//345/205/250/351/200/211_/346/234/252/351/200/211/344/270/255.svg +15 -0
  118. package/src/icons//345/205/263/351/227/255/345/244/232/351/200/211.svg +14 -0
  119. package/src/icons//345/205/263/351/227/255/346/212/225/345/261/217.svg +11 -0
  120. package/src/icons//345/233/236/346/224/266/347/253/231.svg +15 -0
  121. package/src/icons//345/244/261/346/225/210.svg +17 -0
  122. package/src/icons//346/207/222/347/214/253/346/222/255/346/224/276/345/231/250-icon.png +0 -0
  123. package/src/icons//346/207/222/347/214/253/346/222/255/346/224/276/345/231/250.png +0 -0
  124. package/src/icons//346/212/225/345/261/217.svg +11 -0
  125. package/src/icons//346/212/225/351/200/201/344/270/255.jpg +0 -0
  126. package/src/icons//346/212/225/351/200/201/344/270/255.svg +21 -0
  127. package/src/icons//346/222/255/346/224/276.svg +3 -0
  128. package/src/icons//346/232/202/345/201/234.svg +4 -0
  129. package/src/icons//346/232/202/346/227/240.svg +21 -0
  130. package/src/icons//346/233/264/345/244/232/346/223/215/344/275/234.svg +11 -0
  131. package/src/icons//347/224/265/350/247/206.svg +18 -0
  132. package/src/icons//347/247/273/345/212/250/347/253/257_/350/203/214/346/231/257.webp +0 -0
  133. package/src/icons//350/203/214/346/231/257.png +0 -0
  134. package/src/icons//350/277/224/345/233/236.svg +13 -0
  135. package/src/icons//350/277/233/345/205/245/345/205/250/345/261/217.svg +13 -0
  136. package/src/icons//351/200/200/345/207/272/345/205/250/345/261/217.svg +15 -0
  137. package/src/icons//351/200/211/346/213/251.svg +15 -0
  138. package/src/icons//351/237/263/351/207/217.svg +13 -0
  139. package/src/index.d.ts +9 -0
  140. package/src/lzc-video-player.scss +7 -0
  141. package/src/lzc-video-player.ts +6 -0
  142. package/src/main.ts +62 -0
  143. package/src/model.ts +77 -0
  144. package/src/quasar-variables.sass +10 -0
  145. package/src/router/index.ts +74 -0
  146. package/src/stores/pinia.ts +3 -0
  147. package/src/stores/playlist.ts +146 -0
  148. package/src/use/useKeyBind.ts +61 -0
  149. package/src/use/useMultipleEdit.ts +60 -0
  150. package/src/use/useSdk.ts +5 -0
  151. package/src/use/useSubtitle.ts +39 -0
  152. package/src/use/useUtils.ts +22 -0
  153. package/src/use/useVideoFrame.ts +60 -0
  154. package/src/views/Home.ts +99 -0
  155. package/src/views/mobile/Home.vue +246 -0
  156. package/src/views/mobile/Player.vue +141 -0
  157. package/tailwind.config.js +15 -0
  158. package/tsconfig.config.json +8 -0
  159. package/tsconfig.json +20 -0
  160. package/vite.config.lib.ts +88 -0
  161. package/vite.config.ts +122 -0
  162. package/vue-shim.d.ts +4 -0
@@ -0,0 +1,510 @@
1
+ <script lang="ts" setup>
2
+ import { ref, computed, onMounted, onUnmounted, watch, inject } from "vue"
3
+ import debounce from "lodash.debounce"
4
+ import type { NativePlayerAPI } from "./NativePlayer"
5
+ import { nativePlayerKey } from "./playerKey"
6
+ import { useHistoryInfo } from "@/stores/playlist"
7
+ import { isSourceEqual } from "@/use/useUtils"
8
+ import { t } from "@/i18n"
9
+ import { isMobile } from "@/use/useUtils"
10
+ import PrevSvg from "@/icons/上一个.svg?inline"
11
+ import NextSvg from "@/icons/下一个.svg?inline"
12
+ import PlaySvg from "@/icons/播放.svg?inline"
13
+ import PausedSvg from "@/icons/暂停.svg?inline"
14
+ import FullscreenSvg from "@/icons/进入全屏.svg?inline"
15
+ import ExitFullscreenSvg from "@/icons/退出全屏.svg?inline"
16
+
17
+ const playerRef = inject(nativePlayerKey, ref<NativePlayerAPI | null>(null))
18
+ const player = computed(() => playerRef.value)
19
+ const store = useHistoryInfo()
20
+ const fullscreenEnabled =
21
+ typeof document !== "undefined" && document.fullscreenEnabled
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ currentTime?: number
26
+ duration?: number
27
+ isPlaying?: boolean
28
+ isFullscreen?: boolean
29
+ prevEnabled?: boolean
30
+ nextEnabled?: boolean
31
+ hidePrevButton?: boolean
32
+ hideNextButton?: boolean
33
+ hidePlayList?: boolean
34
+ hideSubtitle?: boolean
35
+ hideResolution?: boolean
36
+ }>(),
37
+ {
38
+ currentTime: 0,
39
+ duration: 0,
40
+ isPlaying: true,
41
+ isFullscreen: false,
42
+ prevEnabled: false,
43
+ nextEnabled: false,
44
+ hidePrevButton: false,
45
+ hideNextButton: false,
46
+ hidePlayList: false,
47
+ hideSubtitle: false,
48
+ hideResolution: false,
49
+ },
50
+ )
51
+
52
+ const emit = defineEmits<{
53
+ (e: "open-modal", page: string, ev: Event): void
54
+ }>()
55
+
56
+ const seeking = ref(false)
57
+ const seekValue = ref(0)
58
+
59
+ let rateQualityOff: (() => void) | null = null
60
+
61
+ function clampPercent(value: number): number {
62
+ if (!Number.isFinite(value)) return 0
63
+ return Math.min(100, Math.max(0, value))
64
+ }
65
+
66
+ const progressPercent = computed(() => {
67
+ const d = props.duration
68
+ if (d <= 0) return 0
69
+ return clampPercent((props.currentTime / d) * 100)
70
+ })
71
+
72
+ const displayProgressPercent = computed(() => {
73
+ const d = props.duration
74
+ if (seeking.value && d > 0) {
75
+ return clampPercent((seekValue.value / d) * 100)
76
+ }
77
+ return progressPercent.value
78
+ })
79
+
80
+ const displayCurrentTime = computed(() =>
81
+ seeking.value ? seekValue.value : props.currentTime,
82
+ )
83
+
84
+ const playbackRateText = ref("倍速")
85
+ const resolutionText = ref("清晰度")
86
+
87
+ function togglePlay() {
88
+ const p = player.value
89
+ if (!p) return
90
+ if (p.paused()) {
91
+ p.play()
92
+ } else {
93
+ p.pause()
94
+ }
95
+ }
96
+
97
+ const progressTrackRef = ref<HTMLElement | null>(null)
98
+ const dragging = ref(false)
99
+ const hoverActive = ref(false)
100
+ const hoverPercent = ref(0)
101
+
102
+ const hoverTime = computed(() => {
103
+ const d = props.duration
104
+ if (d <= 0) return 0
105
+ const seconds = hoverPercent.value * d
106
+ return Math.max(0, Math.min(d, seconds))
107
+ })
108
+
109
+ function isIosPortraitFakeFullscreen(): boolean {
110
+ const p = player.value
111
+ if (!p?.isFullscreen()) return false
112
+ return p.hasClass("vjs-ios-fake-fullscreen-portrait")
113
+ }
114
+
115
+ function getPercentFromClientPoint(clientX: number, clientY: number): number {
116
+ const el = progressTrackRef.value
117
+ if (!el) return 0
118
+ const rect = el.getBoundingClientRect()
119
+ let pct = 0
120
+ if (isIosPortraitFakeFullscreen()) {
121
+ if (rect.height <= 0) return 0
122
+ // In portrait fake fullscreen, container rotates 90deg.
123
+ // Screen Y axis maps to timeline direction (top -> start, bottom -> end).
124
+ pct = (clientY - rect.top) / rect.height
125
+ } else {
126
+ if (rect.width <= 0) return 0
127
+ pct = (clientX - rect.left) / rect.width
128
+ }
129
+ pct = Math.max(0, Math.min(1, pct))
130
+ return pct
131
+ }
132
+
133
+ function isTouchLikeEvent(
134
+ e: MouseEvent | TouchEvent,
135
+ ): e is TouchEvent {
136
+ return "touches" in e || "changedTouches" in e
137
+ }
138
+
139
+ function getPercentFromEvent(e: MouseEvent | TouchEvent): number {
140
+ if (isTouchLikeEvent(e)) {
141
+ const t = e.touches[0] ?? e.changedTouches[0]
142
+ return getPercentFromClientPoint(t?.clientX ?? 0, t?.clientY ?? 0)
143
+ }
144
+ return getPercentFromClientPoint(e.clientX, e.clientY)
145
+ }
146
+
147
+ function onProgressMouseEnter(e: MouseEvent) {
148
+ if (isMobile()) return
149
+ hoverActive.value = true
150
+ hoverPercent.value = getPercentFromClientPoint(e.clientX, e.clientY)
151
+ }
152
+
153
+ function onProgressMouseMove(e: MouseEvent) {
154
+ if (isMobile()) return
155
+ hoverActive.value = true
156
+ hoverPercent.value = getPercentFromClientPoint(e.clientX, e.clientY)
157
+ }
158
+
159
+ function onProgressMouseLeave() {
160
+ hoverActive.value = false
161
+ }
162
+
163
+ function applySeek(pct: number) {
164
+ seeking.value = true
165
+ const d = props.duration || 1
166
+ seekValue.value = pct * d
167
+ const p = player.value
168
+ if (p) p.currentTime(seekValue.value)
169
+ }
170
+
171
+ function onProgressPointerDown(e: MouseEvent | TouchEvent) {
172
+ e.preventDefault()
173
+ dragging.value = true
174
+ const p = player.value
175
+ if (p) {
176
+ p.setScrubbing(true)
177
+ p.trigger({ type: "lzcSeekBarDown" })
178
+ }
179
+ const pct = getPercentFromEvent(e)
180
+ applySeek(pct)
181
+ if (e instanceof MouseEvent && !isMobile()) {
182
+ hoverActive.value = true
183
+ hoverPercent.value = pct
184
+ }
185
+ const onMove = (ev: MouseEvent | TouchEvent) => {
186
+ const p = getPercentFromEvent(ev)
187
+ applySeek(p)
188
+ if (ev instanceof MouseEvent && !isMobile()) {
189
+ hoverActive.value = true
190
+ hoverPercent.value = p
191
+ }
192
+ if (player.value) {
193
+ player.value.trigger({ type: "lzcSeekBarMove" })
194
+ }
195
+ }
196
+ const onUp = () => {
197
+ onSeekEnd()
198
+ document.removeEventListener("mousemove", onMove as (e: MouseEvent) => void)
199
+ document.removeEventListener("mouseup", onUp)
200
+ document.removeEventListener("touchmove", onMove as (e: TouchEvent) => void)
201
+ document.removeEventListener("touchend", onUp)
202
+ }
203
+ document.addEventListener("mousemove", onMove as (e: MouseEvent) => void)
204
+ document.addEventListener("mouseup", onUp)
205
+ document.addEventListener("touchmove", onMove as (e: TouchEvent) => void, {
206
+ passive: false,
207
+ })
208
+ document.addEventListener("touchend", onUp)
209
+ }
210
+
211
+ function onSeekEnd() {
212
+ dragging.value = false
213
+ const p = player.value
214
+ if (p) {
215
+ p.setScrubbing(false)
216
+ p.trigger({ type: "lzcSeekBarUp" })
217
+ }
218
+ hoverActive.value = false
219
+ // 不在此处设置 seeking=false,等 seeked 事件后再清除,避免进度点先跳过去又回弹
220
+ }
221
+
222
+ function onSeeked() {
223
+ seeking.value = false
224
+ const p = player.value
225
+ if (p) p.setScrubbing(false)
226
+ if (p) seekValue.value = p.currentTime() ?? 0
227
+ }
228
+
229
+ function subscribeRateQuality(p: NativePlayerAPI) {
230
+ const onRatechange = () => {
231
+ const r = p.playbackRate()
232
+ playbackRateText.value =
233
+ r !== 1
234
+ ? `${r} x`
235
+ : t(
236
+ "src.components.video.components.lzc_play_rate.speed_btn_text_2",
237
+ "倍速",
238
+ )
239
+ }
240
+ const updateResolutionText = () => {
241
+ const castMode = (p as { isCastMode?: () => boolean }).isCastMode?.()
242
+ if (castMode) {
243
+ resolutionText.value = t(
244
+ "src.components.video.components.lzc_resolution_button.definition_btn_text_2",
245
+ "清晰度",
246
+ )
247
+ return
248
+ }
249
+ const resolution = p.currentResolution()
250
+ if (resolution == undefined) {
251
+ resolutionText.value = t(
252
+ "src.components.video.components.lzc_resolution_button.definition_btn_text_2",
253
+ "清晰度",
254
+ )
255
+ return
256
+ }
257
+ let text: string
258
+ if (resolution.origin === true) {
259
+ text = t(
260
+ "src.components.video.components.lzc_resolution_button.original_quality_btn_text",
261
+ "原始画质",
262
+ )
263
+ } else {
264
+ text = `${resolution.res} P`
265
+ }
266
+ if (resolution.auto === true) {
267
+ text = t(
268
+ "src.components.video.components.lzc_resolution_button.auto_quality_btn_text",
269
+ "自动({{text}})",
270
+ { text },
271
+ )
272
+ }
273
+ resolutionText.value = text
274
+ }
275
+ const debouncedUpdateResolution = debounce(updateResolutionText, 1000, {
276
+ maxWait: 3000,
277
+ })
278
+ p.on("seeked", onSeeked)
279
+ p.on("ratechange", onRatechange)
280
+ p.on("timeupdate", debouncedUpdateResolution)
281
+ p.qualityLevels().on("change", updateResolutionText)
282
+ onRatechange()
283
+ updateResolutionText()
284
+ rateQualityOff = () => {
285
+ p.off("seeked", onSeeked)
286
+ p.off("ratechange", onRatechange)
287
+ p.off("timeupdate", debouncedUpdateResolution)
288
+ }
289
+ }
290
+
291
+ function openModal(page: string, ev: Event) {
292
+ emit("open-modal", page, ev)
293
+ }
294
+
295
+ function toggleFullscreen() {
296
+ const p = player.value
297
+ if (!p) return
298
+ if (p.isFullscreen()) {
299
+ p.exitFullscreen()
300
+ } else {
301
+ p.requestFullscreen()
302
+ }
303
+ }
304
+
305
+ function doPrev() {
306
+ const p = player.value
307
+ if (!p || !props.prevEnabled) return
308
+ if (p.playPrev) {
309
+ p.playPrev()
310
+ return
311
+ }
312
+ const currentIndex = store.infos.findIndex((info) =>
313
+ isSourceEqual(p.currentSrc(), info),
314
+ )
315
+ if (currentIndex > 0) {
316
+ p.trigger({ type: "openVideo", info: store.infos[currentIndex - 1] })
317
+ }
318
+ }
319
+
320
+ function doNext() {
321
+ const p = player.value
322
+ if (!p || !props.nextEnabled) return
323
+ if (p.playNext) {
324
+ p.playNext()
325
+ return
326
+ }
327
+ const currentIndex = store.infos.findIndex((info) =>
328
+ isSourceEqual(p.currentSrc(), info),
329
+ )
330
+ if (currentIndex >= 0 && currentIndex < store.infos.length - 1) {
331
+ p.trigger({ type: "openVideo", info: store.infos[currentIndex + 1] })
332
+ }
333
+ }
334
+
335
+ function formatTime(sec: number): string {
336
+ if (!Number.isFinite(sec) || sec < 0) return "0:00"
337
+ const totalSeconds = Math.floor(sec)
338
+ const h = Math.floor(totalSeconds / 3600)
339
+ const m = Math.floor((totalSeconds % 3600) / 60)
340
+ const s = totalSeconds % 60
341
+ if (h > 0) {
342
+ return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
343
+ }
344
+ return `${m}:${s.toString().padStart(2, "0")}`
345
+ }
346
+
347
+ onMounted(() => {
348
+ const p = player.value
349
+ if (p) subscribeRateQuality(p)
350
+ })
351
+
352
+ onUnmounted(() => {
353
+ rateQualityOff?.()
354
+ })
355
+
356
+ watch(
357
+ player,
358
+ (p) => {
359
+ rateQualityOff?.()
360
+ if (p) subscribeRateQuality(p)
361
+ },
362
+ { immediate: false },
363
+ )
364
+ </script>
365
+
366
+ <template>
367
+ <template v-if="player">
368
+ <div
369
+ class="lzc-native-controls relative flex flex-col text-white text-sm font-semibold leading-8"
370
+ >
371
+ <div class="flex flex-row items-center w-full min-h-[3.2rem]">
372
+ <span
373
+ class="text-[12px] font-semibold leading-[20px] text-white mr-[17px] tabular-nums shrink-0"
374
+ >
375
+ {{ formatTime(displayCurrentTime) }}
376
+ </span>
377
+ <div
378
+ ref="progressTrackRef"
379
+ class="flex-1 min-w-0 py-3 flex items-center cursor-pointer -my-3"
380
+ role="slider"
381
+ :aria-valuenow="displayProgressPercent"
382
+ aria-valuemin="0"
383
+ aria-valuemax="100"
384
+ tabindex="0"
385
+ @mousedown="onProgressPointerDown"
386
+ @touchstart="onProgressPointerDown"
387
+ @mouseenter="onProgressMouseEnter"
388
+ @mousemove="onProgressMouseMove"
389
+ @mouseleave="onProgressMouseLeave"
390
+ >
391
+ <div class="flex-1 min-w-0 h-0.5 relative bg-white/30 rounded-sm">
392
+ <div
393
+ v-if="hoverActive && props.duration > 0"
394
+ class="absolute bottom-[calc(100%+8px)] -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-white text-[11px] leading-4 whitespace-nowrap pointer-events-none"
395
+ :style="{ left: hoverPercent * 100 + '%' }"
396
+ >
397
+ {{ formatTime(hoverTime) }}
398
+ </div>
399
+ <div
400
+ class="absolute left-0 top-1/2 -translate-y-1/2 h-0.5 rounded-sm bg-[#5f86ff] pointer-events-none"
401
+ :style="{ width: displayProgressPercent + '%' }"
402
+ />
403
+ <div
404
+ class="absolute top-1/2 w-[12px] h-[12px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#5f86ff] shadow-[0_0_0_0.7rem_rgba(95,134,255,0.15)] pointer-events-none"
405
+ :style="{ left: displayProgressPercent + '%' }"
406
+ />
407
+ </div>
408
+ </div>
409
+ <span
410
+ class="text-[12px] font-semibold leading-[20px] text-white ml-[17px] tabular-nums shrink-0"
411
+ >
412
+ {{ formatTime(props.duration) }}
413
+ </span>
414
+ </div>
415
+ <div
416
+ class="flex flex-row items-center justify-between w-full min-h-16 gap-2"
417
+ >
418
+ <div
419
+ class="flex flex-row items-center shrink-0 gap-[12px] sm:gap-[16px]"
420
+ >
421
+ <img
422
+ v-if="!hidePrevButton"
423
+ :src="PrevSvg"
424
+ class="w-[16px] h-[16px] object-contain opacity-100 cursor-pointer flex-shrink-0"
425
+ :class="{ 'opacity-50 cursor-not-allowed': !prevEnabled }"
426
+ @click="doPrev"
427
+ />
428
+ <img
429
+ :src="props.isPlaying ? PausedSvg : PlaySvg"
430
+ class="w-[16px] h-[16px] object-contain opacity-100 cursor-pointer flex-shrink-0"
431
+ @click="togglePlay"
432
+ />
433
+ <img
434
+ v-if="!hideNextButton"
435
+ :src="NextSvg"
436
+ class="w-[16px] h-[16px] object-contain opacity-100 cursor-pointer flex-shrink-0"
437
+ :class="{ 'opacity-50 cursor-not-allowed': !nextEnabled }"
438
+ @click="doNext"
439
+ />
440
+ </div>
441
+ <div
442
+ class="flex flex-row items-center shrink-0 gap-[12px] sm:gap-[16px] text-white leading-[20px] text-[14px] font-semibold"
443
+ >
444
+ <div
445
+ v-if="!hideSubtitle"
446
+ class="w-auto h-full min-w-[2.4rem] py-0 px-1 border-none bg-transparent cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
447
+ @click="openModal('Subtitle', $event)"
448
+ >
449
+ {{
450
+ t("src.components.video.components.lzc_sub_button.text", "字幕")
451
+ }}
452
+ </div>
453
+ <div
454
+ v-if="!hidePlayList"
455
+ class="w-auto h-full min-w-[2.4rem] py-0 px-1 border-none bg-transparent cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
456
+ @click="openModal('LzcPlaylist', $event)"
457
+ >
458
+ {{
459
+ t(
460
+ "src.components.video.components.lzc_playlist_button.text_playlist",
461
+ "播放列表",
462
+ )
463
+ }}
464
+ </div>
465
+ <div
466
+ class="w-auto h-full min-w-[2.4rem] py-0 px-1 border-none bg-transparent cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
467
+ @click="openModal('LzcPlayrate', $event)"
468
+ >
469
+ {{ playbackRateText }}
470
+ </div>
471
+ <div
472
+ v-if="!hideResolution"
473
+ class="w-auto h-full min-w-[2.4rem] py-0 px-1 border-none bg-transparent cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
474
+ @click="openModal('LzcResolution', $event)"
475
+ >
476
+ {{ resolutionText }}
477
+ </div>
478
+ <img
479
+ v-if="!isMobile() && fullscreenEnabled"
480
+ :src="props.isFullscreen ? ExitFullscreenSvg : FullscreenSvg"
481
+ class="w-[16px] h-[16px] object-contain opacity-100 cursor-pointer flex-shrink-0"
482
+ @click="toggleFullscreen"
483
+ />
484
+ </div>
485
+ </div>
486
+ </div>
487
+ </template>
488
+ </template>
489
+
490
+ <style scoped>
491
+ .lzc-native-controls {
492
+ padding-right: calc(1.25rem + var(--lzc-player-safe-area-right, 0px));
493
+ padding-bottom: var(--lzc-safe-area-inset-bottom);
494
+ padding-left: calc(1.25rem + var(--lzc-player-safe-area-left, 0px));
495
+ transition: padding 260ms cubic-bezier(0.22, 0.61, 0.36, 1);
496
+ }
497
+
498
+ @media (min-width: 640px) {
499
+ .lzc-native-controls {
500
+ padding-right: calc(2rem + var(--lzc-player-safe-area-right, 0px));
501
+ padding-left: calc(2rem + var(--lzc-player-safe-area-left, 0px));
502
+ }
503
+ }
504
+
505
+ @media (prefers-reduced-motion: reduce) {
506
+ .lzc-native-controls {
507
+ transition: none;
508
+ }
509
+ }
510
+ </style>
@@ -0,0 +1,133 @@
1
+ <script lang="ts" setup>
2
+ import { computed, inject, ref, type CSSProperties } from "vue"
3
+ import { useElementSize } from "@vueuse/core"
4
+ import List from "@/components/Video/components/LzcModal/list.vue"
5
+ import Playrate from "@/components/Video/components/LzcModal/playrate.vue"
6
+ import Resolution from "@/components/Video/components/LzcModal/resolution.vue"
7
+ import Subtitle from "@/components/Video/components/LzcModal/subtitle.vue"
8
+ import type { NativePlayerAPI } from "./NativePlayer"
9
+ import { nativePlayerKey } from "./playerKey"
10
+
11
+ const playerRef = inject(nativePlayerKey, ref<NativePlayerAPI | null>(null))
12
+ const player = computed(() => playerRef.value)
13
+
14
+ const props = defineProps<{
15
+ visible: boolean
16
+ activePage: string
17
+ position: { right: number; bottom: number }
18
+ directMode?: boolean
19
+ }>()
20
+
21
+ const emit = defineEmits<{ (e: "close"): void }>()
22
+
23
+ const modalRootRef = ref<HTMLElement | null>(null)
24
+ const modalContentRef = ref<HTMLElement | null>(null)
25
+ const { width: rootWidth, height: rootHeight } = useElementSize(modalRootRef)
26
+ const { width: contentWidth, height: contentHeight } = useElementSize(modalContentRef)
27
+
28
+ function clamp(value: number, min: number, max: number): number {
29
+ return Math.min(Math.max(value, min), max)
30
+ }
31
+
32
+ function readInheritedPx(name: string): number {
33
+ if (typeof window === "undefined" || typeof document === "undefined") return 0
34
+ const source = modalRootRef.value ?? document.documentElement
35
+ const value = window
36
+ .getComputedStyle(source)
37
+ .getPropertyValue(name)
38
+ const parsed = Number.parseFloat(value)
39
+ if (Number.isFinite(parsed) && value.trim().endsWith("px")) return parsed
40
+
41
+ const probe = document.createElement("div")
42
+ probe.style.position = "absolute"
43
+ probe.style.visibility = "hidden"
44
+ probe.style.pointerEvents = "none"
45
+ probe.style.width = `var(${name})`
46
+ source.appendChild(probe)
47
+ const measured = probe.getBoundingClientRect().width
48
+ probe.remove()
49
+ return Number.isFinite(measured) ? measured : 0
50
+ }
51
+
52
+ function edgeMargin(side: "top" | "right" | "bottom" | "left"): number {
53
+ if (side === "left" || side === "right") {
54
+ return 20 + readInheritedPx(`--lzc-player-safe-area-${side}`)
55
+ }
56
+ return 20 + readInheritedPx(`--lzc-safe-area-inset-${side}`)
57
+ }
58
+
59
+ function calcRight(
60
+ targetRight: number,
61
+ elemWidth: number,
62
+ containerWidth: number,
63
+ ): number {
64
+ const minRight = edgeMargin("right")
65
+ const maxRight = Math.max(
66
+ minRight,
67
+ containerWidth - elemWidth - edgeMargin("left"),
68
+ )
69
+ return clamp(targetRight - elemWidth / 2, minRight, maxRight)
70
+ }
71
+
72
+ function calcBottom(
73
+ targetBottom: number,
74
+ elemHeight: number,
75
+ containerHeight: number,
76
+ ): number {
77
+ const minBottom = edgeMargin("bottom")
78
+ const maxBottom = Math.max(
79
+ minBottom,
80
+ containerHeight - elemHeight - edgeMargin("top"),
81
+ )
82
+ return clamp(targetBottom + 35, minBottom, maxBottom)
83
+ }
84
+
85
+ const style = computed<CSSProperties>(() => ({
86
+ right: `${calcRight(props.position.right, contentWidth.value, rootWidth.value)}px`,
87
+ bottom: `${calcBottom(props.position.bottom, contentHeight.value, rootHeight.value)}px`,
88
+ position: "absolute",
89
+ }))
90
+
91
+ function close() {
92
+ emit("close")
93
+ }
94
+
95
+ const isBlockedInDirectMode = computed(() => {
96
+ if (!props.directMode) return false
97
+ return ["Subtitle", "LzcPlaylist", "LzcResolution"].includes(props.activePage)
98
+ })
99
+ </script>
100
+
101
+ <template>
102
+ <div
103
+ ref="modalRootRef"
104
+ v-show="visible"
105
+ class="absolute inset-0 z-[10] bg-gradient-to-b from-black/80 to-transparent"
106
+ @click.self="close"
107
+ >
108
+ <div
109
+ v-if="!isBlockedInDirectMode"
110
+ ref="modalContentRef"
111
+ class="absolute z-[11] overflow-visible text-white"
112
+ :style="style"
113
+ @click.stop
114
+ >
115
+ <Playrate
116
+ v-if="activePage === 'LzcPlayrate' && player"
117
+ :player="(player as any)"
118
+ />
119
+ <Resolution
120
+ v-else-if="activePage === 'LzcResolution' && player"
121
+ :player="(player as any)"
122
+ />
123
+ <List
124
+ v-else-if="activePage === 'LzcPlaylist' && player"
125
+ :player="(player as any)"
126
+ />
127
+ <Subtitle
128
+ v-else-if="activePage === 'Subtitle' && player"
129
+ :player="(player as any)"
130
+ />
131
+ </div>
132
+ </div>
133
+ </template>