@grfzhl/vue-hls-player 1.0.4 → 1.0.6

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.
package/README.md CHANGED
@@ -120,6 +120,11 @@ it can show and hide the video control panel
120
120
  subtitles to add as tracks to the video
121
121
 
122
122
  ### Last release:
123
+ v1.0.5
124
+ 1. Load transcriptions additionally to subtitles
125
+ 2. Add styled transcription block for better readability
126
+ 3. Improve interaction and dynamic params
127
+
123
128
  v1.0.4
124
129
  1. Make subtitles dynamic
125
130
  2. Add new switch to disable the subtitle block
@@ -1,11 +1,13 @@
1
1
  <template>
2
2
  <div class="video-container">
3
- <media-theme-sutro>
3
+ <media-theme-sutro class="video-player-theme-container">
4
4
  <video
5
+ class="hls-player"
5
6
  slot="media"
6
7
  @pause="pause"
7
- @ended="pause"
8
8
  @keyup="changeSpeed"
9
+ @ended="onVideoEnd"
10
+ @seek="seekVideo"
9
11
  ref="video"
10
12
  :poster="previewImageLink"
11
13
  :controls="false"
@@ -27,9 +29,17 @@
27
29
  :label="subtitle.label" :default="i === 0" />
28
30
  </video>
29
31
  </media-theme-sutro>
30
- <div ref="subtitlesContainer" class="custom-subtitles"></div>
32
+ <div class="custom-subtitles" v-show="!showTranscriptBlock">
33
+ <div class="subtitle-text" ref="subtitlesContainer" style="display: none;"></div>
34
+ </div>
31
35
  </div>
32
- <SubtitleBlock v-if="showSubtitleBlock" :subtitle="currentSubtitle" :cursor="videoCursor"></SubtitleBlock>
36
+ <SubtitleBlock
37
+ :subtitle="currentSubtitle"
38
+ :cursor="videoCursor"
39
+ :showTranscriptBlock="showTranscriptBlock"
40
+ @seek="seekVideo"
41
+ @toggleTranscript="toggleTranscript">
42
+ </SubtitleBlock>
33
43
  </template>
34
44
 
35
45
  <script setup>
@@ -72,22 +82,29 @@ const props = defineProps({
72
82
  * true, if showing separate
73
83
  * block with transcripts
74
84
  */
75
- showSubtitleBlock: {
85
+ showTranscriptBlock: {
76
86
  type: Boolean,
77
87
  default: true
88
+ },
89
+ isFullscreen: {
90
+ type: Boolean,
91
+ default: false
78
92
  }
79
93
  })
80
94
 
81
- const emit = defineEmits(['pause', 'test'])
95
+ const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change'])
82
96
  const video = ref(null)
83
97
  const subtitlesContainer = ref(null)
84
98
  const currentSubtitleLang = ref(null)
85
99
  const videoCursor = ref(0)
100
+ const isFullscreen = ref(false);
86
101
 
87
102
  onMounted(() => {
88
103
  prepareVideoPlayer()
89
104
  if (video.value) {
105
+ checkFullscreen();
90
106
  video.value.addEventListener('timeupdate', updateCurrentTime);
107
+ document.addEventListener('fullscreenchange', onFullscreenChange);
91
108
  }
92
109
  })
93
110
 
@@ -97,6 +114,7 @@ onUpdated(() => {
97
114
  onUnmounted(() => {
98
115
  if (video.value) {
99
116
  video.value.removeEventListener('timeupdate', updateCurrentTime);
117
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
100
118
  }
101
119
  });
102
120
 
@@ -105,16 +123,34 @@ const currentSubtitle = computed(() => {
105
123
  const current = props.subtitles.filter((subt) => {
106
124
  return subt.lang === currentSubtitleLang.value
107
125
  })
108
- console.log("found current", current)
109
126
  return current.length ? current[0] : null
110
127
  }
111
128
  return null
112
129
  })
113
130
 
131
+ function checkFullscreen() {
132
+ isFullscreen.value = !!document.fullscreenElement;
133
+ };
134
+
135
+ function onFullscreenChange() {
136
+ checkFullscreen();
137
+ emit('video-fullscreen-change', document.fullscreenElement)
138
+ };
139
+
140
+
114
141
  function updateCurrentTime() {
115
142
  videoCursor.value = video.value.currentTime;
116
143
  }
117
144
 
145
+ function toggleTranscript() {
146
+ props.showTranscriptBlock = !props.showTranscriptBlock
147
+ }
148
+
149
+ function seekVideo(time) {
150
+ video.value.currentTime = time;
151
+ video.play()
152
+ }
153
+
118
154
  function prepareVideoPlayer() {
119
155
  let hls = new Hls()
120
156
  let stream = props.link
@@ -162,10 +198,15 @@ function prepareVideoPlayer() {
162
198
 
163
199
  function pause() {
164
200
  const currentTime = video?.value?.currentTime || 0
165
-
166
201
  emit('pause', currentTime)
167
202
  }
168
203
 
204
+ function onVideoEnd() {
205
+ const currentTime = video?.value?.currentTime || 0
206
+ pause()
207
+ emit('video-ended', { currentTime: currentTime, video });
208
+ }
209
+
169
210
  function changeSpeed(e) {
170
211
  if (e.key === 'w' && video && video.value) {
171
212
  video.value.playbackRate = video.value.playbackRate + 0.25
@@ -186,17 +227,26 @@ function changeSpeed(e) {
186
227
  position: absolute;
187
228
  left: 50%;
188
229
  width: auto;
189
- max-width: 90%;
230
+ max-width: 95%;
190
231
  text-align: center;
191
232
  background: rgba(0, 0, 0, 0.7);
233
+ border-radius: 6px;
234
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
235
+ transform: translate(-50%) translateY(calc(-100% - 60px));
236
+ }
237
+ .custom-subtitles .subtitle-text {
192
238
  color: white;
193
- font-size: 16px;
239
+ font-size: 14px;
194
240
  font-family: Arial, sans-serif;
195
241
  line-height: 1.5;
196
- padding: 10px 20px;
197
- border-radius: 10px;
198
- box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
199
- margin-top: -120px;
200
- transform: translateX(-50%) translateY(-100%);
242
+ padding: 8px 10px;
243
+ }
244
+
245
+ .video-player-theme-container, .hls-player {
246
+ width: 100%;
247
+ }
248
+
249
+ .video-container {
250
+ position: relative;
201
251
  }
202
252
  </style>
@@ -1,77 +1,291 @@
1
1
  <template>
2
- <div class="transcript-container"
3
- ref="subtitlesContainer">
4
- <ul v-if="cues.length" class="subtitles">
5
- <li v-for="cue of cues" :class="{'current-highlight': cursor >= cue.start && cursor <= cue.end}">
6
- <span class="seconds">{{ secondsToTime(cue.start) }} - {{ secondsToTime(cue.end) }}</span>
7
- <span class="text">{{ cue.text }}</span>
2
+ <div class="transcript-container" ref="subtitlesContainer">
3
+ <div class="transcript-toggle">
4
+ <button data-headlessui-state="open" @click="toggleTranscript()">
5
+ <div class="icon">
6
+ <svg v-if="!showTranscriptBlock" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon duration-200 h-4 w-4 text-gray-900 stroke-1"><path d="m9 18 6-6-6-6"></path></svg>
7
+ <svg v-if="showTranscriptBlock" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon rotate-90 transform duration-200 h-4 w-4 text-gray-900 stroke-1"><path d="m9 18 6-6-6-6"></path></svg>
8
+ </div>
9
+ Transcript
10
+ </button>
11
+ </div>
12
+ <ul v-if="txtCues.length && showTranscriptBlock" class="subtitles">
13
+ <li
14
+ v-for="(txtCue, index) in txtCues"
15
+ :key="index"
16
+ :class="{ 'current-highlight': isTxtCueActive(txtCue) }"
17
+ @click="seekTo(txtCue.start)"
18
+ >
19
+ <div class="play-icon">
20
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-play lucide transform duration-200 h-4 w-4 text-gray-900 stroke-1"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 8 6 4-6 4Z"/></svg>
21
+ </div>
22
+ <div class="content">
23
+ <span class="meta">
24
+ <span class="seconds">{{ secondsToTime(txtCue.start) }} - {{ secondsToTime(txtCue.end) }}</span>
25
+ <span class="narrator">{{ txtCue.dialog[0].speaker }}</span>
26
+ </span>
27
+ <span class="text">
28
+ <span
29
+ v-for="(word, wordIndex) in txtCue.dialog[0].text.split('')"
30
+ :key="wordIndex"
31
+ :class="{ 'active-word': isWordActive(txtCue, word, wordIndex, index) && isTxtCueActive(txtCue) }"
32
+ >
33
+ {{ word }}
34
+ </span>
35
+ </span>
36
+ </div>
8
37
  </li>
9
38
  </ul>
10
39
  </div>
11
40
  </template>
12
41
 
13
42
  <style lang="css" scoped>
14
- .subtitles li.current-highlight {
15
- font-weight: bold;
16
- }
43
+ .transcript-toggle {
44
+ font-weight: bold;
45
+ }
46
+ .transcript-toggle button {
47
+ display: flex;
48
+ }
49
+ .transcript-toggle button .icon {
50
+ padding: 3px;
51
+ }
52
+ .transcript-container {
53
+ max-height: 400px;
54
+ overflow-y: scroll;
55
+ border-radius: 8px;
56
+ border: 1px solid var(--outline-gray-1);
57
+ padding: 10px;
58
+ font-family: Arial, sans-serif;
59
+ }
60
+
61
+ .subtitles {
62
+ list-style: none;
63
+ padding: 0;
64
+ margin: 0;
65
+ }
66
+
67
+ .subtitles li {
68
+ padding: 5px;
69
+ cursor: pointer;
70
+ display: flex;
71
+ }
72
+
73
+ .subtitles li .play-icon {
74
+ min-width: 24px;
75
+ padding-top: 4px;
76
+ }
77
+
78
+ .subtitles li .content .meta {
79
+ display: flex;
80
+ padding-bottom: 4px;
81
+ }
82
+ .subtitles li .content .meta .seconds {
83
+ font-weight: 500;
84
+ }
85
+
86
+ .subtitles li .content .meta .narrator {
87
+ padding-left: 10px;
88
+ font-weight: 500;
89
+ }
90
+
91
+ .subtitles li.current-highlight {
92
+ }
93
+
94
+ .subtitles li .active-word {
95
+ background-color: var(--outline-gray-1);
96
+ }
97
+
98
+ .subtitles li .seconds {
99
+ display: block;
100
+ }
101
+
102
+ .subtitles li .text .narrator {
103
+ display: block;
104
+ color: #333;
105
+ }
17
106
  </style>
18
107
 
19
108
  <script setup>
20
- import { onMounted, onUpdated, ref } from 'vue'
109
+ import { onMounted, watch, ref } from 'vue';
21
110
 
22
111
  const props = defineProps({
23
112
  subtitle: {
24
113
  type: Object,
25
- default: null
114
+ default: null,
26
115
  },
27
116
  cursor: {
28
117
  type: Number,
29
- default: 0
118
+ default: 0,
30
119
  },
31
- })
120
+ showTranscriptBlock: {
121
+ type: Boolean,
122
+ default: true
123
+ }
124
+ });
32
125
 
33
- const emit = defineEmits(['pause', 'test'])
34
- const subtitlesContainer = ref(null)
35
- const cues = ref([])
126
+ const emit = defineEmits(['seek', 'toggleTranscript']);
127
+ const subtitlesContainer = ref(null);
128
+ const vttCues = ref([]);
129
+ const txtCues = ref([]);
130
+ const currentCue = ref(null);
36
131
 
37
- onMounted(async () => {
38
- if(props.subtitle) {
39
- cues.value = await parseVTT(props.subtitle.link)
132
+
133
+ const loadCues = async () => {
134
+ if (props.subtitle) {
135
+ const vttPath = props.subtitle.link;
136
+ const txtPath = vttPath.replace(/\.vtt$/, '.txt');
137
+
138
+ vttCues.value = await parseVTT(vttPath);
139
+ txtCues.value = await parseTXT(txtPath);
140
+ }
141
+ };
142
+
143
+ onMounted(loadCues);
144
+
145
+ watch(
146
+ () => props.subtitle,
147
+ async (newSubtitle) => {
148
+ if (newSubtitle) await loadCues();
40
149
  }
41
- })
150
+ );
42
151
 
43
- onUpdated(async () => {
44
- if(props.subtitle) {
45
- cues.value = await parseVTT(props.subtitle.link)
152
+ watch(
153
+ () => props.cursor,
154
+ (currentTime) => {
155
+ highlightActiveCue(currentTime);
156
+ checkCurrentCue(currentTime)
46
157
  }
47
- // auto scroll text
48
- if(subtitlesContainer) {
49
- const activeSubtitle = subtitlesContainer?.value?.querySelectorAll('li.current-highlight')[0];
158
+ );
159
+
160
+ /**
161
+ * highlgiht the current transcript part
162
+ * and keep it scrolled up
163
+ * @param currentTime
164
+ */
165
+ function highlightActiveCue(currentTime) {
166
+ if (subtitlesContainer.value) {
167
+ const activeSubtitle = subtitlesContainer.value.querySelectorAll('li.current-highlight')[0];
50
168
  if (activeSubtitle) {
51
- subtitlesContainer.value.scrollTop = activeSubtitle.offsetTop - subtitlesContainer.value.offsetTop;
169
+ subtitlesContainer.value.scrollTop =
170
+ activeSubtitle.offsetTop - subtitlesContainer.value.offsetTop;
52
171
  }
53
172
  }
54
- })
173
+ }
55
174
 
175
+ function toggleTranscript() {
176
+ emit('toggleTranscript', null)
177
+ }
56
178
 
179
+ function seekTo(time) {
180
+ emit('seek', time);
181
+ }
182
+
183
+ /**
184
+ * returns true, if the given txt
185
+ * is currently played
186
+ * @param txtCue
187
+ */
188
+ function isTxtCueActive(txtCue) {
189
+ return props.cursor >= txtCue.start && props.cursor <= txtCue.end;
190
+ }
191
+
192
+ /**
193
+ * true if the word part can be highlighed
194
+ * as currently active
195
+ * @param txtCue
196
+ * @param word
197
+ * @param wordIndex
198
+ * @param txtIndex
199
+ */
200
+ function isWordActive(txtCue, word, wordIndex, txtIndex) {
201
+ if(!currentCue.value || !word || !txtCue) {
202
+ return false
203
+ }
204
+ const startPos = txtCue.dialog[0].text.indexOf(currentCue.value)
205
+ const endPos = startPos + currentCue.value.length
206
+ if(wordIndex >= startPos && wordIndex < endPos) {
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ function checkCurrentCue(currentCursor) {
213
+ Array.from(vttCues.value).forEach((a, index) => {
214
+ if(currentCursor >= a.start && currentCursor <= a.end) {
215
+ currentCue.value = a.text
216
+ }
217
+ });
218
+ }
219
+
220
+ /**
221
+ * get subtitles from the
222
+ * vtt files
223
+ * @param fileUrl
224
+ */
57
225
  async function parseVTT(fileUrl) {
58
226
  const response = await fetch(fileUrl);
59
227
  const text = await response.text();
60
228
  const cues = [];
61
- const lines = text.split("\n");
229
+ const lines = text.split('\n');
62
230
  let cue = null;
63
231
 
64
232
  for (let i = 0; i < lines.length; i++) {
65
233
  const line = lines[i].trim();
66
234
  if (!line) continue;
67
235
 
68
- if (line.includes("-->")) {
69
- const [start, end] = line.split(" --> ");
70
- cue = { start: timeToSeconds(start), end: timeToSeconds(end), text: "" };
236
+ if (line.includes('-->')) {
237
+ const [start, end] = line.split(' --> ');
238
+ cue = { start: timeToSeconds(start), end: timeToSeconds(end), text: '' };
71
239
  } else if (cue) {
72
- cue.text += line + " ";
240
+ cue.text += line + ' ';
241
+ }
242
+
243
+ if (cue && (!lines[i + 1] || lines[i + 1].includes('-->'))) {
244
+ cues.push(cue);
245
+ cue = null;
246
+ }
247
+ }
248
+ return cues;
249
+ }
250
+
251
+ /**
252
+ * get the raw transcriptions
253
+ * from the txt files
254
+ * @param fileUrl
255
+ */
256
+ async function parseTXT(fileUrl) {
257
+ const response = await fetch(fileUrl);
258
+ const text = await response.text();
259
+ const cues = [];
260
+ const lines = text.split('\n');
261
+ let cue = null;
262
+ let dialog = null;
263
+ for (let i = 0; i < lines.length; i++) {
264
+ const line = lines[i].trim();
265
+ if (!line) continue;
266
+
267
+ /**
268
+ * extract every transcript part by time
269
+ */
270
+ if (line.match(/^\d{2}:\d{2}:\d{2}:\d{2} - \d{2}:\d{2}:\d{2}:\d{2}/)) {
271
+ const [start, end] = line.split(' - ');
272
+ cue = {
273
+ start: timeToSeconds(start),
274
+ end: timeToSeconds(end),
275
+ dialog: []
276
+ };
277
+ dialog = {
278
+ text: '',
279
+ speaker: ''
280
+ }
281
+ } else if (cue && dialog.text == '' && dialog.speaker == '') {
282
+ dialog.speaker = line;
283
+ } else if (cue && dialog.speaker !== '') {
284
+ dialog.text += line + ' ';
73
285
  }
74
- if (cue && (!lines[i + 1] || lines[i + 1].includes("-->"))) {
286
+
287
+ if (cue && (!lines[i + 1] || lines[i + 1].match(/^\d{2}:\d{2}:\d{2}:\d{2} - \d{2}:\d{2}:\d{2}:\d{2}/))) {
288
+ cue.dialog.push(dialog);
75
289
  cues.push(cue);
76
290
  cue = null;
77
291
  }
@@ -79,8 +293,9 @@ async function parseVTT(fileUrl) {
79
293
  return cues;
80
294
  }
81
295
 
296
+ // Hilfsfunktionen
82
297
  function timeToSeconds(timestamp) {
83
- const [hours, minutes, seconds] = timestamp.split(":").map(parseFloat);
298
+ const [hours, minutes, seconds] = timestamp.split(':').map(parseFloat);
84
299
  return hours * 3600 + minutes * 60 + seconds;
85
300
  }
86
301
 
@@ -91,7 +306,4 @@ function secondsToTime(seconds) {
91
306
  const pad = (num) => String(num).padStart(2, '0');
92
307
  return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
93
308
  }
94
- </script>
95
-
96
-
97
-
309
+ </script>
@@ -5,14 +5,19 @@
5
5
  :progress="progress"
6
6
  :isMuted="isMuted"
7
7
  :isControls="isControls"
8
+ :onVideoEnd="onVideoEnd"
9
+ :isFullscreen="isFullscreen"
10
+ :showTranscriptBlock="showTranscriptBlock"
8
11
  @pause="pause"
12
+ @video-ended="onVideoEnd"
13
+ @video-fullscreen-change="onFullscreenChange"
9
14
  />
10
15
  </template>
11
16
 
12
17
  <script setup>
13
18
  import BasePlayer from './BasePlayer.vue'
14
19
 
15
- const emit = defineEmits(['pause'])
20
+ const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change'])
16
21
 
17
22
  defineProps({
18
23
  previewImageLink: {
@@ -35,9 +40,26 @@ defineProps({
35
40
  type: Boolean,
36
41
  default: true
37
42
  },
43
+ isFullscreen: {
44
+ type: Boolean,
45
+ default: false
46
+ },
47
+ showTranscriptBlock: {
48
+ type: Boolean,
49
+ default: true
50
+ }
38
51
  })
39
52
 
40
53
  function pause(currentTime) {
41
54
  emit('pause', currentTime)
42
55
  }
56
+
57
+ function onVideoEnd(data) {
58
+ pause()
59
+ emit('video-ended', data);
60
+ }
61
+
62
+ function onFullscreenChange(data) {
63
+ emit('video-fullscreen-change', data);
64
+ }
43
65
  </script>
@@ -2,7 +2,11 @@
2
2
  <VDefaultVideoPlayer
3
3
  v-if="type === 'default'"
4
4
  @pause="pause"
5
+ @video-fullscreen-change="onVideoFullScreenChange"
6
+ @video-ended="onVideoEnd"
5
7
  :previewImageLink="previewImageLink"
8
+ :showTranscriptBlock="showTranscriptBlock"
9
+ :isFullscreen="isFullscreen"
6
10
  :link="link"
7
11
  :progress="progress"
8
12
  :isMuted="isMuted"
@@ -19,7 +23,7 @@
19
23
  import VDefaultVideoPlayer from './VDefaultVideoPlayer.vue'
20
24
  import VPreviewVideoPlayer from './VPreviewVideoPlayer.vue'
21
25
 
22
- const emit = defineEmits(['pause'])
26
+ const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change'])
23
27
 
24
28
  defineProps({
25
29
  previewImageLink: {
@@ -46,9 +50,23 @@ defineProps({
46
50
  type: Boolean,
47
51
  default: true
48
52
  },
53
+ isFullscreen: {
54
+ type: Boolean,
55
+ default: false
56
+ },
57
+ showTranscriptBlock: {
58
+ type: Boolean,
59
+ default: true
60
+ }
49
61
  })
50
62
 
51
63
  function pause(currentTime) {
52
64
  emit('pause', currentTime)
53
65
  }
66
+ function onVideoFullScreenChange(data) {
67
+ emit('video-fullscreen-change', data)
68
+ }
69
+ function onVideoEnd(data) {
70
+ emit('video-ended', data);
71
+ }
54
72
  </script>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@grfzhl/vue-hls-player",
3
3
  "private": false,
4
- "version": "1.0.4",
4
+ "version": "1.0.6",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"