@celestia-island/hikari 0.4.4

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 (187) hide show
  1. package/README.md +54 -0
  2. package/package.json +63 -0
  3. package/src/DemoApp.vue +400 -0
  4. package/src/components/HkActionBar.tsx +15 -0
  5. package/src/components/HkAlert.scss +121 -0
  6. package/src/components/HkAlert.tsx +85 -0
  7. package/src/components/HkAvatar.tsx +29 -0
  8. package/src/components/HkBadge.scss +117 -0
  9. package/src/components/HkBadge.tsx +50 -0
  10. package/src/components/HkBreadcrumb.tsx +39 -0
  11. package/src/components/HkButton.scss +128 -0
  12. package/src/components/HkButton.tsx +74 -0
  13. package/src/components/HkCard.scss +49 -0
  14. package/src/components/HkCard.tsx +38 -0
  15. package/src/components/HkCheckbox.scss +102 -0
  16. package/src/components/HkCheckbox.tsx +85 -0
  17. package/src/components/HkCollapse.tsx +48 -0
  18. package/src/components/HkColorPicker.scss +148 -0
  19. package/src/components/HkColorPicker.tsx +204 -0
  20. package/src/components/HkConfirmDialog.scss +12 -0
  21. package/src/components/HkConfirmDialog.tsx +75 -0
  22. package/src/components/HkDateTimePicker.scss +361 -0
  23. package/src/components/HkDateTimePicker.tsx +424 -0
  24. package/src/components/HkDivider.scss +85 -0
  25. package/src/components/HkDivider.tsx +51 -0
  26. package/src/components/HkDraggableGrid.scss +132 -0
  27. package/src/components/HkDraggableGrid.tsx +291 -0
  28. package/src/components/HkDraggableList.scss +110 -0
  29. package/src/components/HkDraggableList.tsx +238 -0
  30. package/src/components/HkDrawer.scss +175 -0
  31. package/src/components/HkDrawer.tsx +171 -0
  32. package/src/components/HkEmptyState.scss +42 -0
  33. package/src/components/HkEmptyState.tsx +41 -0
  34. package/src/components/HkErrorBoundary.scss +64 -0
  35. package/src/components/HkErrorBoundary.tsx +82 -0
  36. package/src/components/HkGaugeRing.scss +4 -0
  37. package/src/components/HkGaugeRing.tsx +130 -0
  38. package/src/components/HkHoverRevealAction.scss +66 -0
  39. package/src/components/HkHoverRevealAction.tsx +77 -0
  40. package/src/components/HkIcon.tsx +43 -0
  41. package/src/components/HkIconButton.tsx +42 -0
  42. package/src/components/HkImageViewer.scss +33 -0
  43. package/src/components/HkImageViewer.tsx +250 -0
  44. package/src/components/HkInput.scss +172 -0
  45. package/src/components/HkInput.tsx +175 -0
  46. package/src/components/HkKbd.tsx +26 -0
  47. package/src/components/HkKeywordSearchModal.scss +148 -0
  48. package/src/components/HkKeywordSearchModal.tsx +308 -0
  49. package/src/components/HkListTransition.scss +131 -0
  50. package/src/components/HkListTransition.tsx +29 -0
  51. package/src/components/HkLocalePickerPopup.tsx +42 -0
  52. package/src/components/HkLogo.scss +36 -0
  53. package/src/components/HkLogo.tsx +93 -0
  54. package/src/components/HkMarkdownRenderer.scss +141 -0
  55. package/src/components/HkMarkdownRenderer.tsx +123 -0
  56. package/src/components/HkMediaControlBar.scss +65 -0
  57. package/src/components/HkMediaControlBar.tsx +115 -0
  58. package/src/components/HkMediaPlayer.scss +29 -0
  59. package/src/components/HkMediaPlayer.tsx +210 -0
  60. package/src/components/HkMediaSlider.scss +54 -0
  61. package/src/components/HkMediaSlider.tsx +81 -0
  62. package/src/components/HkMediaVisualizer.scss +7 -0
  63. package/src/components/HkMediaVisualizer.tsx +119 -0
  64. package/src/components/HkMinimap.scss +76 -0
  65. package/src/components/HkMinimap.tsx +255 -0
  66. package/src/components/HkModal.scss +318 -0
  67. package/src/components/HkModal.tsx +481 -0
  68. package/src/components/HkModalBreadcrumb.scss +55 -0
  69. package/src/components/HkModalBreadcrumb.tsx +113 -0
  70. package/src/components/HkMorphingTabs.scss +141 -0
  71. package/src/components/HkMorphingTabs.tsx +113 -0
  72. package/src/components/HkNavItem.scss +81 -0
  73. package/src/components/HkNavItem.tsx +157 -0
  74. package/src/components/HkNumberInput.scss +154 -0
  75. package/src/components/HkNumberInput.tsx +161 -0
  76. package/src/components/HkPasswordInput.scss +289 -0
  77. package/src/components/HkPasswordInput.tsx +533 -0
  78. package/src/components/HkPhaseTransition.scss +25 -0
  79. package/src/components/HkPhaseTransition.tsx +40 -0
  80. package/src/components/HkPopover.scss +82 -0
  81. package/src/components/HkPopover.tsx +410 -0
  82. package/src/components/HkPopupSelect.scss +136 -0
  83. package/src/components/HkPopupSelect.tsx +199 -0
  84. package/src/components/HkProgressBar.scss +106 -0
  85. package/src/components/HkProgressBar.tsx +61 -0
  86. package/src/components/HkProgressDialog.tsx +67 -0
  87. package/src/components/HkProgressRing.scss +56 -0
  88. package/src/components/HkProgressRing.tsx +82 -0
  89. package/src/components/HkRadio.scss +119 -0
  90. package/src/components/HkRadio.tsx +75 -0
  91. package/src/components/HkRollingNumber.scss +37 -0
  92. package/src/components/HkRollingNumber.tsx +97 -0
  93. package/src/components/HkScrollContainer.scss +101 -0
  94. package/src/components/HkScrollContainer.tsx +362 -0
  95. package/src/components/HkSearchInput.scss +132 -0
  96. package/src/components/HkSearchInput.tsx +117 -0
  97. package/src/components/HkSelect.scss +143 -0
  98. package/src/components/HkSelect.tsx +230 -0
  99. package/src/components/HkSelectionGrid.scss +177 -0
  100. package/src/components/HkSelectionGrid.tsx +99 -0
  101. package/src/components/HkSelectionWaterfall.scss +93 -0
  102. package/src/components/HkSelectionWaterfall.tsx +90 -0
  103. package/src/components/HkSidebar.scss +81 -0
  104. package/src/components/HkSidebar.tsx +45 -0
  105. package/src/components/HkSkeleton.scss +44 -0
  106. package/src/components/HkSkeleton.tsx +30 -0
  107. package/src/components/HkSkeletonList.tsx +35 -0
  108. package/src/components/HkSpinner.scss +67 -0
  109. package/src/components/HkSpinner.tsx +41 -0
  110. package/src/components/HkSplash.scss +60 -0
  111. package/src/components/HkSplash.tsx +40 -0
  112. package/src/components/HkSwitch.scss +157 -0
  113. package/src/components/HkSwitch.tsx +72 -0
  114. package/src/components/HkTable.scss +198 -0
  115. package/src/components/HkTable.tsx +246 -0
  116. package/src/components/HkTabs.scss +109 -0
  117. package/src/components/HkTabs.tsx +111 -0
  118. package/src/components/HkTag.scss +88 -0
  119. package/src/components/HkTag.tsx +44 -0
  120. package/src/components/HkTextarea.scss +72 -0
  121. package/src/components/HkTextarea.tsx +104 -0
  122. package/src/components/HkTimeline.scss +139 -0
  123. package/src/components/HkTimeline.tsx +95 -0
  124. package/src/components/HkToast.scss +168 -0
  125. package/src/components/HkToast.tsx +175 -0
  126. package/src/components/HkTooltip.scss +47 -0
  127. package/src/components/HkTooltip.tsx +108 -0
  128. package/src/components/HkTree.scss +188 -0
  129. package/src/components/HkTree.tsx +217 -0
  130. package/src/components/HkTrendChart.tsx +180 -0
  131. package/src/components/HkWindowedItem.scss +17 -0
  132. package/src/components/HkWindowedItem.tsx +107 -0
  133. package/src/components/HkZoomToolbar.scss +35 -0
  134. package/src/components/HkZoomToolbar.tsx +69 -0
  135. package/src/composables/useBreakpoint.ts +41 -0
  136. package/src/composables/useClipboard.ts +37 -0
  137. package/src/composables/useConfirm.ts +35 -0
  138. package/src/composables/useProgressDialog.ts +52 -0
  139. package/src/composables/useReportedTransition.ts +69 -0
  140. package/src/composables/useScrollWindow.ts +21 -0
  141. package/src/composables/useToast.ts +2 -0
  142. package/src/demo.scss +24 -0
  143. package/src/demo.ts +22 -0
  144. package/src/env.d.ts +6 -0
  145. package/src/i18n/context.ts +89 -0
  146. package/src/i18n/locales/ar/components.json +43 -0
  147. package/src/i18n/locales/ar/components.toml +40 -0
  148. package/src/i18n/locales/de/components.json +43 -0
  149. package/src/i18n/locales/de/components.toml +40 -0
  150. package/src/i18n/locales/en/components.json +43 -0
  151. package/src/i18n/locales/en/components.toml +40 -0
  152. package/src/i18n/locales/es/components.json +43 -0
  153. package/src/i18n/locales/es/components.toml +40 -0
  154. package/src/i18n/locales/fr/components.json +43 -0
  155. package/src/i18n/locales/fr/components.toml +40 -0
  156. package/src/i18n/locales/ja/components.json +43 -0
  157. package/src/i18n/locales/ja/components.toml +40 -0
  158. package/src/i18n/locales/ko/components.json +43 -0
  159. package/src/i18n/locales/ko/components.toml +40 -0
  160. package/src/i18n/locales/pt/components.json +43 -0
  161. package/src/i18n/locales/pt/components.toml +40 -0
  162. package/src/i18n/locales/ru/components.json +43 -0
  163. package/src/i18n/locales/ru/components.toml +40 -0
  164. package/src/i18n/locales/zh-Hans/components.json +43 -0
  165. package/src/i18n/locales/zh-Hans/components.toml +40 -0
  166. package/src/i18n/locales/zh-Hant/components.json +43 -0
  167. package/src/i18n/locales/zh-Hant/components.toml +40 -0
  168. package/src/index.ts +137 -0
  169. package/src/render.ts +18 -0
  170. package/src/runtime/animationBus.ts +278 -0
  171. package/src/runtime/cronBus.ts +25 -0
  172. package/src/runtime/index.ts +12 -0
  173. package/src/runtime/useAsyncData.ts +30 -0
  174. package/src/runtime/useBreakpoint.ts +62 -0
  175. package/src/runtime/useClipboard.ts +37 -0
  176. package/src/runtime/useConfirm.ts +35 -0
  177. package/src/runtime/useOverlay.ts +82 -0
  178. package/src/runtime/usePopupManager.ts +89 -0
  179. package/src/runtime/useToast.ts +140 -0
  180. package/src/styles/index.scss +10 -0
  181. package/src/theme/index.ts +5 -0
  182. package/src/theme/presets.ts +307 -0
  183. package/src/theme/theme.scss +17 -0
  184. package/src/theme/useBackgroundLuminance.ts +145 -0
  185. package/src/theme/useSolarTime.ts +101 -0
  186. package/src/theme/useTheme.ts +141 -0
  187. package/src/tokens.scss +26 -0
@@ -0,0 +1,141 @@
1
+ .hk-markdown {
2
+ font-size: 0.875rem;
3
+ line-height: 1.7;
4
+ color: var(--hi-color-text-primary, #c9d1d9);
5
+ position: relative;
6
+ }
7
+
8
+ .hk-markdown-body {
9
+ min-height: 120px;
10
+ }
11
+
12
+ .hk-markdown h1,
13
+ .hk-markdown h2,
14
+ .hk-markdown h3 {
15
+ font-weight: 700;
16
+ margin: 1.25em 0 0.5em;
17
+ color: var(--hi-color-text-primary, #c9d1d9);
18
+ }
19
+
20
+ .hk-markdown h1 { font-size: 1.5em; }
21
+ .hk-markdown h2 { font-size: 1.25em; }
22
+ .hk-markdown h3 { font-size: 1.1em; }
23
+
24
+ .hk-markdown p {
25
+ margin: 0.5em 0;
26
+ }
27
+
28
+ .hk-markdown ul,
29
+ .hk-markdown ol {
30
+ margin: 0.5em 0;
31
+ padding-left: 1.5em;
32
+ }
33
+
34
+ .hk-markdown li {
35
+ margin: 0.25em 0;
36
+ }
37
+
38
+ .hk-markdown code {
39
+ font-family: ui-monospace, monospace;
40
+ font-size: 0.85em;
41
+ background: var(--hi-color-bg-subtle, #21262d);
42
+ padding: 0.15em 0.35em;
43
+ border-radius: 3px;
44
+ }
45
+
46
+ .hk-md-code {
47
+ position: relative;
48
+ margin: 0.75em 0;
49
+ padding: 1em;
50
+ border-radius: 4px;
51
+ font-size: 0.8125rem;
52
+ line-height: 1.5;
53
+ user-select: none;
54
+ cursor: pointer;
55
+ overflow-x: auto;
56
+ background: var(--hi-color-bg-subtle, #0d1117);
57
+ }
58
+
59
+ .hk-md-code pre {
60
+ margin: 0;
61
+ }
62
+
63
+ .hk-md-code-lang {
64
+ position: absolute;
65
+ top: 0.25em;
66
+ right: 0.5em;
67
+ font-size: 0.625em;
68
+ color: var(--hi-color-text-tertiary, #8b949e);
69
+ opacity: 0.6;
70
+ font-weight: 500;
71
+ text-transform: uppercase;
72
+ letter-spacing: 0.05em;
73
+ pointer-events: none;
74
+ }
75
+
76
+ .hk-md-plain {
77
+ margin: 0;
78
+ padding: 0.5rem 0;
79
+ font-family: ui-monospace, monospace;
80
+ font-size: 0.75rem;
81
+ line-height: 1.6;
82
+ color: var(--hi-color-text-primary, #c9d1d9);
83
+ white-space: pre-wrap;
84
+ word-break: break-word;
85
+ }
86
+
87
+ .hk-markdown blockquote {
88
+ margin: 0.75em 0;
89
+ padding: 0.5em 1em;
90
+ border-left: 3px solid var(--hi-color-text-tertiary, #8b949e);
91
+ color: var(--hi-color-text-secondary, #8b949e);
92
+ }
93
+
94
+ .hk-markdown table {
95
+ width: 100%;
96
+ border-collapse: collapse;
97
+ margin: 0.75em 0;
98
+ }
99
+
100
+ .hk-markdown th,
101
+ .hk-markdown td {
102
+ padding: 0.5em 0.75em;
103
+ border: 1px solid var(--hi-color-border, #30363d);
104
+ text-align: left;
105
+ }
106
+
107
+ .hk-markdown th {
108
+ font-weight: 600;
109
+ background: var(--hi-color-bg-subtle, #21262d);
110
+ }
111
+
112
+ .hk-markdown a {
113
+ color: var(--hi-color-primary, #58a6ff);
114
+ text-decoration: none;
115
+ }
116
+
117
+ .hk-markdown a:hover {
118
+ text-decoration: underline;
119
+ }
120
+
121
+ .hk-markdown hr {
122
+ border: none;
123
+ border-top: 1px solid var(--hi-color-border, #30363d);
124
+ margin: 1em 0;
125
+ }
126
+
127
+ .hk-markdown img {
128
+ max-width: 100%;
129
+ border-radius: 4px;
130
+ }
131
+
132
+ .hk-markdown-overlay {
133
+ position: absolute;
134
+ inset: 0;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ background: color-mix(in srgb, var(--hi-color-surface) 70%, transparent);
139
+ backdrop-filter: blur(4px);
140
+ z-index: 10;
141
+ }
@@ -0,0 +1,123 @@
1
+ import { computed, defineComponent, ref, watch } from "vue";
2
+ import HSpinner from "./HkSpinner";
3
+ import "./HkMarkdownRenderer.scss";
4
+
5
+ let _marked: any = null;
6
+ let _DOMPurify: any = null;
7
+
8
+ async function ensureMarked(): Promise<any> {
9
+ if (_marked) return _marked;
10
+ try {
11
+ // @ts-ignore
12
+ const mod = await import("marked");
13
+ _marked = mod.marked;
14
+ _marked.setOptions({ gfm: true, breaks: true });
15
+ return _marked;
16
+ } catch {
17
+ return null as never;
18
+ }
19
+ }
20
+
21
+ async function ensureDOMPurify() {
22
+ if (_DOMPurify) return _DOMPurify;
23
+ try {
24
+ // @ts-ignore
25
+ const mod = await import("dompurify");
26
+ _DOMPurify = mod.default;
27
+ return _DOMPurify;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function tryHighlight(code: string, lang?: string): string {
34
+ try {
35
+ const hljs = (globalThis as Record<string, unknown>).hljs as
36
+ | { highlight: (code: string, opts: { language?: string }) => { value: string } }
37
+ | undefined;
38
+ if (hljs?.highlight) {
39
+ const result = hljs.highlight(code, { language: lang });
40
+ return result.value;
41
+ }
42
+ } catch {
43
+ // fall through
44
+ }
45
+ return code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
46
+ }
47
+
48
+ function renderPlain(content: string): string {
49
+ const escaped = content
50
+ .replace(/&/g, "&amp;")
51
+ .replace(/</g, "&lt;")
52
+ .replace(/>/g, "&gt;");
53
+ return `<pre class="hk-md-plain">${escaped}</pre>`;
54
+ }
55
+
56
+ async function renderMarkdown(content: string): Promise<string> {
57
+ const [marked, DOMPurify] = await Promise.all([ensureMarked(), ensureDOMPurify()]);
58
+
59
+ if (!marked || !DOMPurify) {
60
+ return renderPlain(content);
61
+ }
62
+
63
+ const renderer = new (marked as unknown as { Renderer: new () => Record<string, unknown> }).Renderer();
64
+
65
+ (renderer as Record<string, unknown>).code = function (tok: { text: string; lang?: string }) {
66
+ const highlighted = tryHighlight(tok.text, tok.lang);
67
+ const langAttr = tok.lang ? ` language-${tok.lang}` : "";
68
+ const langLabel = tok.lang ? `<span class="hk-md-code-lang">${tok.lang}</span>` : "";
69
+ return `<div class="hk-md-code">${langLabel}<pre><code class="hljs${langAttr}">${highlighted}</code></pre></div>`;
70
+ };
71
+
72
+ const raw = marked.parse(content, { renderer, async: false });
73
+ const html = typeof raw === "string" ? raw : "";
74
+ return DOMPurify.sanitize(html);
75
+ }
76
+
77
+ export default defineComponent({
78
+ name: "HkMarkdownRenderer",
79
+ props: {
80
+ content: { type: String, required: true },
81
+ loading: { type: Boolean, default: false },
82
+ plain: { type: Boolean, default: false },
83
+ },
84
+ setup(props) {
85
+ const renderedHtml = ref("");
86
+ const parseError = ref(false);
87
+
88
+ async function parse() {
89
+ if (!props.content) {
90
+ renderedHtml.value = "";
91
+ return;
92
+ }
93
+ parseError.value = false;
94
+ try {
95
+ if (props.plain) {
96
+ renderedHtml.value = renderPlain(props.content);
97
+ } else {
98
+ renderedHtml.value = await renderMarkdown(props.content);
99
+ }
100
+ } catch {
101
+ parseError.value = true;
102
+ renderedHtml.value = renderPlain(props.content);
103
+ }
104
+ }
105
+
106
+ watch(
107
+ () => [props.content, props.plain],
108
+ () => { void parse(); },
109
+ { immediate: true },
110
+ );
111
+
112
+ return () => (
113
+ <div class="hk-markdown" data-plain={props.plain || undefined} data-loading={props.loading}>
114
+ <div class="hk-markdown-body" innerHTML={renderedHtml.value} />
115
+ {props.loading && (
116
+ <div class="hk-markdown-overlay">
117
+ <HSpinner center />
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ },
123
+ });
@@ -0,0 +1,65 @@
1
+ .hk-media-controls {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--space-8);
5
+ padding: var(--space-6) var(--space-10);
6
+ background: rgb(var(--color-surface) / 0.85);
7
+ backdrop-filter: blur(var(--blur-sm));
8
+ border-top: 1px solid var(--border-faint);
9
+ color: rgb(var(--color-text));
10
+ }
11
+
12
+ .hk-media-btn.hk-btn {
13
+ min-width: 28px;
14
+ height: 28px;
15
+ padding: 0 var(--space-6);
16
+ font-size: var(--text-xs);
17
+ font-weight: 600;
18
+ }
19
+
20
+ .hk-media-play.hk-btn {
21
+ background: rgb(var(--color-primary) / 0.14);
22
+ color: rgb(var(--color-primary));
23
+
24
+ &:hover:not(:disabled) {
25
+ background: rgb(var(--color-primary) / 0.24);
26
+ }
27
+ }
28
+
29
+ .hk-media-controls-seek {
30
+ flex: 1;
31
+ min-width: 0;
32
+ }
33
+
34
+ .hk-media-time {
35
+ font-size: var(--text-2xs);
36
+ font-variant-numeric: tabular-nums;
37
+ color: rgb(var(--color-muted));
38
+ white-space: nowrap;
39
+ user-select: none;
40
+ }
41
+
42
+ .hk-media-volume {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: var(--space-4);
46
+ }
47
+
48
+ .hk-media-controls-vol {
49
+ width: 64px;
50
+ }
51
+
52
+ .hk-media-rate {
53
+ font-variant-numeric: tabular-nums;
54
+ }
55
+
56
+ // ── Mobile responsive ─────────────────────────────────────────────
57
+ // On a phone the play button + time labels + 64px volume slider leave
58
+ // almost no room for the seek bar to be usable. Phone volume is set via
59
+ // hardware keys, so hide the volume control on narrow screens and let
60
+ // the seek bar reclaim that width.
61
+ @media (max-width: 600px) {
62
+ .hk-media-volume {
63
+ display: none;
64
+ }
65
+ }
@@ -0,0 +1,115 @@
1
+ import { defineComponent } from "vue";
2
+ import { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from "lucide-vue-next";
3
+
4
+ import HButton from "./HkButton";
5
+ import { useI18n } from "../i18n/context";
6
+ import HMediaSlider from "./HkMediaSlider";
7
+ import "./HkMediaControlBar.scss";
8
+
9
+ export function formatMediaTime(sec: number): string {
10
+ if (!Number.isFinite(sec) || sec < 0) sec = 0;
11
+ const m = Math.floor(sec / 60);
12
+ const s = Math.floor(sec % 60);
13
+ return `${m}:${s.toString().padStart(2, "0")}`;
14
+ }
15
+
16
+ /**
17
+ * Shared transport controls for any media controller — the same bar is used
18
+ * under the audio and video players. Purely presentational: it reflects
19
+ * controller state via props and forwards intents via events. `HMediaSlider`
20
+ * backs both the seek track and the volume track.
21
+ */
22
+ export default defineComponent({
23
+ name: "HkMediaControlBar",
24
+ props: {
25
+ playing: { type: Boolean, default: false },
26
+ current: { type: Number, default: 0 },
27
+ duration: { type: Number, default: 0 },
28
+ progress: { type: Number, default: 0 },
29
+ bufferedPct: { type: Number, default: 0 },
30
+ volume: { type: Number, default: 1 },
31
+ muted: { type: Boolean, default: false },
32
+ rate: { type: Number, default: 1 },
33
+ isFullscreen: { type: Boolean, default: false },
34
+ showFullscreen: { type: Boolean, default: false },
35
+ disabled: { type: Boolean, default: false },
36
+ },
37
+ emits: {
38
+ togglePlay: () => true,
39
+ seek: (_ratio: number) => true,
40
+ setVolume: (_v: number) => true,
41
+ toggleMute: () => true,
42
+ cycleRate: () => true,
43
+ toggleFullscreen: () => true,
44
+ },
45
+ setup(props, { emit }) {
46
+ const { t } = useI18n();
47
+ return () => (
48
+ <div class="hk-media-controls">
49
+ <HButton
50
+ variant="ghost"
51
+ size="sm"
52
+ class="hk-media-btn hk-media-play"
53
+ ariaLabel={props.playing ? t("hk.mediaPlayer.pause", "Pause") : t("hk.mediaPlayer.play", "Play")}
54
+ onClick={() => emit("togglePlay")}
55
+ >
56
+ {props.playing ? <Pause size={16} /> : <Play size={16} />}
57
+ </HButton>
58
+
59
+ <HMediaSlider
60
+ class="hk-media-controls-seek"
61
+ ratio={props.progress / 100}
62
+ buffered={props.bufferedPct / 100}
63
+ disabled={props.disabled || props.duration <= 0}
64
+ ariaLabel={t("hk.mediaPlayer.seek", "Seek")}
65
+ onUpdate:ratio={(r) => emit("seek", r)}
66
+ />
67
+
68
+ <span class="hk-media-time">
69
+ {formatMediaTime(props.current)} / {formatMediaTime(props.duration)}
70
+ </span>
71
+
72
+ <div class="hk-media-volume">
73
+ <HButton
74
+ variant="ghost"
75
+ size="sm"
76
+ class="hk-media-btn"
77
+ ariaLabel={props.muted ? t("hk.mediaPlayer.unmute", "Unmute") : t("hk.mediaPlayer.mute", "Mute")}
78
+ onClick={() => emit("toggleMute")}
79
+ >
80
+ {props.muted || props.volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />}
81
+ </HButton>
82
+ <HMediaSlider
83
+ class="hk-media-controls-vol"
84
+ size="sm"
85
+ ratio={props.muted ? 0 : props.volume}
86
+ ariaLabel={t("hk.mediaPlayer.volume", "Volume")}
87
+ onUpdate:ratio={(v) => emit("setVolume", v)}
88
+ />
89
+ </div>
90
+
91
+ <HButton
92
+ variant="ghost"
93
+ size="sm"
94
+ class="hk-media-btn hk-media-rate"
95
+ ariaLabel={t("hk.mediaPlayer.speed", "Playback speed")}
96
+ onClick={() => emit("cycleRate")}
97
+ >
98
+ {props.rate}x
99
+ </HButton>
100
+
101
+ {props.showFullscreen && (
102
+ <HButton
103
+ variant="ghost"
104
+ size="sm"
105
+ class="hk-media-btn"
106
+ ariaLabel={props.isFullscreen ? t("hk.mediaPlayer.exitFullscreen", "Exit fullscreen") : t("hk.mediaPlayer.fullscreen", "Fullscreen")}
107
+ onClick={() => emit("toggleFullscreen")}
108
+ >
109
+ {props.isFullscreen ? <Minimize size={15} /> : <Maximize size={15} />}
110
+ </HButton>
111
+ )}
112
+ </div>
113
+ );
114
+ },
115
+ });
@@ -0,0 +1,29 @@
1
+ .hk-media-player {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-8);
5
+ width: 100%;
6
+ border-radius: var(--radius-sm);
7
+ overflow: hidden;
8
+ background: rgb(0 0 0 / 0.2);
9
+ }
10
+
11
+ .hk-media-player.is-fullscreen {
12
+ width: 100%;
13
+ height: 100%;
14
+ justify-content: flex-end;
15
+ }
16
+
17
+ .hk-media-player-video {
18
+ width: 100%;
19
+ max-height: 60vh;
20
+ display: block;
21
+ background: #000;
22
+ cursor: pointer;
23
+ }
24
+
25
+ .hk-media-player.is-fullscreen .hk-media-player-video {
26
+ max-height: none;
27
+ height: 100%;
28
+ object-fit: contain;
29
+ }
@@ -0,0 +1,210 @@
1
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
2
+
3
+ import HMediaControlBar from "./HkMediaControlBar";
4
+ import HMediaVisualizer from "./HkMediaVisualizer";
5
+ import "./HkMediaPlayer.scss";
6
+
7
+ type MediaKind = "video" | "audio";
8
+
9
+ /** Playback rate presets cycled by the control bar's rate button. */
10
+ export const MEDIA_RATES = [0.5, 1, 1.25, 1.5, 2] as const;
11
+
12
+ /**
13
+ * Thin media player orchestrator. Owns the `<video>` / `<audio>` element,
14
+ * wires the raw media element events into reactive playback state, and
15
+ * composes the pluggable pieces: `HMediaVisualizer` (audio only) + the
16
+ * shared `HMediaControlBar`. Everything interactive lives in the control
17
+ * bar / slider, so this stays a small shell that's easy to swap or embed.
18
+ */
19
+ export default defineComponent({
20
+ name: "HkMediaPlayer",
21
+ props: {
22
+ src: { type: String, required: true },
23
+ type: { type: String as PropType<MediaKind>, required: true },
24
+ poster: { type: String, default: undefined },
25
+ },
26
+ setup(props) {
27
+ const rootRef = ref<HTMLElement | null>(null);
28
+ const mediaRef = ref<HTMLMediaElement | null>(null);
29
+
30
+ const playing = ref(false);
31
+ const current = ref(0);
32
+ const duration = ref(0);
33
+ const buffered = ref(0);
34
+ const volume = ref(1);
35
+ const muted = ref(false);
36
+ const rateIdx = ref(1);
37
+ const isFullscreen = ref(false);
38
+
39
+ const rate = computed(() => MEDIA_RATES[rateIdx.value]);
40
+ const progress = computed(() =>
41
+ duration.value > 0 ? (current.value / duration.value) * 100 : 0,
42
+ );
43
+ const bufferedPct = computed(() =>
44
+ duration.value > 0 ? (buffered.value / duration.value) * 100 : 0,
45
+ );
46
+
47
+ function applyVolume() {
48
+ const m = mediaRef.value;
49
+ if (m) {
50
+ m.volume = volume.value;
51
+ m.muted = muted.value;
52
+ }
53
+ }
54
+ function applyRate() {
55
+ const m = mediaRef.value;
56
+ if (m) m.playbackRate = rate.value;
57
+ }
58
+
59
+ function onTimeUpdate() {
60
+ const m = mediaRef.value;
61
+ if (!m) return;
62
+ current.value = m.currentTime;
63
+ if (m.buffered.length) buffered.value = m.buffered.end(m.buffered.length - 1);
64
+ }
65
+ function onDurationChange() {
66
+ const m = mediaRef.value;
67
+ if (m) duration.value = m.duration || 0;
68
+ }
69
+ function onLoadedMetadata() {
70
+ const m = mediaRef.value;
71
+ if (!m) return;
72
+ duration.value = m.duration || 0;
73
+ volume.value = m.volume;
74
+ muted.value = m.muted;
75
+ applyRate();
76
+ }
77
+ function onVolumeChange() {
78
+ const m = mediaRef.value;
79
+ if (!m) return;
80
+ volume.value = m.volume;
81
+ muted.value = m.muted;
82
+ }
83
+ function onPlay() {
84
+ playing.value = true;
85
+ }
86
+ function onPause() {
87
+ playing.value = false;
88
+ }
89
+ function onEnded() {
90
+ playing.value = false;
91
+ }
92
+
93
+ let cleanupMedia: (() => void) | null = null;
94
+ function attachMedia(media: HTMLMediaElement | null) {
95
+ cleanupMedia?.();
96
+ cleanupMedia = null;
97
+ if (!media) return;
98
+ media.addEventListener("timeupdate", onTimeUpdate);
99
+ media.addEventListener("durationchange", onDurationChange);
100
+ media.addEventListener("loadedmetadata", onLoadedMetadata);
101
+ media.addEventListener("volumechange", onVolumeChange);
102
+ media.addEventListener("play", onPlay);
103
+ media.addEventListener("pause", onPause);
104
+ media.addEventListener("ended", onEnded);
105
+ cleanupMedia = () => {
106
+ media.removeEventListener("timeupdate", onTimeUpdate);
107
+ media.removeEventListener("durationchange", onDurationChange);
108
+ media.removeEventListener("loadedmetadata", onLoadedMetadata);
109
+ media.removeEventListener("volumechange", onVolumeChange);
110
+ media.removeEventListener("play", onPlay);
111
+ media.removeEventListener("pause", onPause);
112
+ media.removeEventListener("ended", onEnded);
113
+ };
114
+ }
115
+
116
+ function onFullscreenChange() {
117
+ isFullscreen.value = !!document.fullscreenElement;
118
+ }
119
+
120
+ onMounted(() => {
121
+ document.addEventListener("fullscreenchange", onFullscreenChange);
122
+ });
123
+
124
+ onBeforeUnmount(() => {
125
+ cleanupMedia?.();
126
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
127
+ });
128
+
129
+ watch(mediaRef, attachMedia, { flush: "post" });
130
+
131
+ function togglePlay() {
132
+ const m = mediaRef.value;
133
+ if (!m) return;
134
+ if (m.paused) void m.play();
135
+ else m.pause();
136
+ }
137
+ function seekToRatio(ratio: number) {
138
+ const m = mediaRef.value;
139
+ if (!m || !duration.value) return;
140
+ const r = Math.max(0, Math.min(1, ratio));
141
+ m.currentTime = r * duration.value;
142
+ current.value = m.currentTime;
143
+ }
144
+ function setVolume(v: number) {
145
+ const val = Math.max(0, Math.min(1, v));
146
+ volume.value = val;
147
+ muted.value = val === 0;
148
+ applyVolume();
149
+ }
150
+ function toggleMute() {
151
+ muted.value = !muted.value;
152
+ applyVolume();
153
+ }
154
+ function cycleRate() {
155
+ rateIdx.value = (rateIdx.value + 1) % MEDIA_RATES.length;
156
+ applyRate();
157
+ }
158
+ function toggleFullscreen() {
159
+ const el = rootRef.value;
160
+ if (!el) return;
161
+ if (document.fullscreenElement) void document.exitFullscreen();
162
+ else void el.requestFullscreen?.();
163
+ }
164
+
165
+ return () => (
166
+ <div
167
+ ref={rootRef}
168
+ class={[
169
+ "hk-media-player",
170
+ `hk-media-player--${props.type}`,
171
+ isFullscreen.value && "is-fullscreen",
172
+ ].filter(Boolean).join(" ")}
173
+ >
174
+ {props.type === "audio" ? (
175
+ <>
176
+ <HMediaVisualizer mediaRef={mediaRef} />
177
+ <audio ref={mediaRef} src={props.src} />
178
+ </>
179
+ ) : (
180
+ <video
181
+ ref={mediaRef}
182
+ src={props.src}
183
+ poster={props.poster}
184
+ class="hk-media-player-video"
185
+ onClick={togglePlay}
186
+ />
187
+ )}
188
+
189
+ <HMediaControlBar
190
+ playing={playing.value}
191
+ current={current.value}
192
+ duration={duration.value}
193
+ progress={progress.value}
194
+ bufferedPct={bufferedPct.value}
195
+ volume={volume.value}
196
+ muted={muted.value}
197
+ rate={rate.value}
198
+ isFullscreen={isFullscreen.value}
199
+ showFullscreen={props.type === "video"}
200
+ onTogglePlay={togglePlay}
201
+ onSeek={seekToRatio}
202
+ onSetVolume={setVolume}
203
+ onToggleMute={toggleMute}
204
+ onCycleRate={cycleRate}
205
+ onToggleFullscreen={toggleFullscreen}
206
+ />
207
+ </div>
208
+ );
209
+ },
210
+ });