vidply 1.0.22 → 1.0.25

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 (66) hide show
  1. package/dist/dev/vidply.HLSRenderer-PNP5OPES.js +255 -0
  2. package/dist/dev/vidply.HLSRenderer-PNP5OPES.js.map +7 -0
  3. package/dist/dev/vidply.HTML5Renderer-LXQ3I45Q.js +12 -0
  4. package/dist/dev/vidply.HTML5Renderer-LXQ3I45Q.js.map +7 -0
  5. package/dist/dev/vidply.TranscriptManager-GZKY44ON.js +1744 -0
  6. package/dist/dev/vidply.TranscriptManager-GZKY44ON.js.map +7 -0
  7. package/dist/dev/vidply.VimeoRenderer-DCETT5IZ.js +213 -0
  8. package/dist/dev/vidply.VimeoRenderer-DCETT5IZ.js.map +7 -0
  9. package/dist/dev/vidply.YouTubeRenderer-QLMMD757.js +227 -0
  10. package/dist/dev/vidply.YouTubeRenderer-QLMMD757.js.map +7 -0
  11. package/dist/dev/vidply.chunk-UEIJOJH6.js +243 -0
  12. package/dist/dev/vidply.chunk-UEIJOJH6.js.map +7 -0
  13. package/dist/dev/vidply.chunk-UH5MTGKF.js +1630 -0
  14. package/dist/dev/vidply.chunk-UH5MTGKF.js.map +7 -0
  15. package/dist/dev/vidply.de-THBIMP4S.js +180 -0
  16. package/dist/dev/vidply.de-THBIMP4S.js.map +7 -0
  17. package/dist/dev/vidply.es-6VWDNNNL.js +180 -0
  18. package/dist/dev/vidply.es-6VWDNNNL.js.map +7 -0
  19. package/dist/{vidply.esm.js → dev/vidply.esm.js} +530 -5082
  20. package/dist/dev/vidply.esm.js.map +7 -0
  21. package/dist/dev/vidply.fr-WHTWCHWT.js +180 -0
  22. package/dist/dev/vidply.fr-WHTWCHWT.js.map +7 -0
  23. package/dist/dev/vidply.ja-BFQNPOFI.js +180 -0
  24. package/dist/dev/vidply.ja-BFQNPOFI.js.map +7 -0
  25. package/dist/{vidply.js → legacy/vidply.js} +7833 -7317
  26. package/dist/legacy/vidply.js.map +7 -0
  27. package/dist/legacy/vidply.min.js +6 -0
  28. package/dist/{vidply.min.meta.json → legacy/vidply.min.meta.json} +120 -94
  29. package/dist/prod/vidply.HLSRenderer-4PW35TCX.min.js +6 -0
  30. package/dist/prod/vidply.HTML5Renderer-XJCSUETP.min.js +6 -0
  31. package/dist/prod/vidply.TranscriptManager-UZ6DUFB6.min.js +6 -0
  32. package/dist/prod/vidply.VimeoRenderer-P3PU27S7.min.js +6 -0
  33. package/dist/prod/vidply.YouTubeRenderer-DGKKWB5M.min.js +6 -0
  34. package/dist/prod/vidply.chunk-BQBGEJF7.min.js +6 -0
  35. package/dist/prod/vidply.chunk-MBUR3U5L.min.js +6 -0
  36. package/dist/prod/vidply.de-SWFW4HYT.min.js +6 -0
  37. package/dist/prod/vidply.es-7BJ2DJAY.min.js +6 -0
  38. package/dist/prod/vidply.esm.min.js +21 -0
  39. package/dist/prod/vidply.fr-DPVR5DFY.min.js +6 -0
  40. package/dist/prod/vidply.ja-PEBVWKVH.min.js +6 -0
  41. package/dist/vidply.css +184 -4
  42. package/dist/vidply.esm.min.meta.json +284 -102
  43. package/dist/vidply.min.css +1 -1
  44. package/package.json +4 -4
  45. package/src/controls/ControlBar.js +3341 -3246
  46. package/src/controls/TranscriptManager.js +2296 -2271
  47. package/src/core/Player.js +4807 -4730
  48. package/src/features/PlaylistManager.js +1203 -1039
  49. package/src/i18n/i18n.js +51 -7
  50. package/src/i18n/languages/de.js +5 -1
  51. package/src/i18n/languages/en.js +5 -1
  52. package/src/i18n/languages/es.js +5 -1
  53. package/src/i18n/languages/fr.js +5 -1
  54. package/src/i18n/languages/ja.js +5 -1
  55. package/src/i18n/translations.js +35 -18
  56. package/src/icons/Icons.js +2 -20
  57. package/src/renderers/HLSRenderer.js +7 -0
  58. package/src/styles/vidply.css +184 -4
  59. package/src/utils/DOMUtils.js +67 -0
  60. package/src/utils/MenuUtils.js +10 -4
  61. package/src/utils/SettingsMenuFactory.js +8 -4
  62. package/src/utils/WindowComponents.js +6 -4
  63. package/dist/vidply.esm.js.map +0 -7
  64. package/dist/vidply.esm.min.js +0 -18
  65. package/dist/vidply.js.map +0 -7
  66. package/dist/vidply.min.js +0 -18
package/src/i18n/i18n.js CHANGED
@@ -2,13 +2,14 @@
2
2
  * Internationalization system
3
3
  */
4
4
 
5
- import { loadBuiltInTranslations } from './translations.js';
5
+ import { getBaseTranslations, getBuiltInLanguageLoaders, loadBuiltInTranslation } from './translations.js';
6
6
 
7
7
  class I18n {
8
8
  constructor() {
9
9
  this.currentLanguage = 'en';
10
- this.translations = loadBuiltInTranslations();
10
+ this.translations = getBaseTranslations();
11
11
  this.loadingPromises = new Map(); // Cache for loading promises
12
+ this.builtInLanguageLoaders = getBuiltInLanguageLoaders();
12
13
  }
13
14
 
14
15
  setLanguage(lang) {
@@ -24,6 +25,47 @@ class I18n {
24
25
  return this.currentLanguage;
25
26
  }
26
27
 
28
+ /**
29
+ * Ensure a language is available, loading built-ins on demand.
30
+ * @param {string} lang Language code
31
+ * @returns {Promise<string|null>} Normalized language code if available
32
+ */
33
+ async ensureLanguage(lang) {
34
+ const normalizedLang = (lang || '').toLowerCase();
35
+ if (!normalizedLang) return this.currentLanguage;
36
+
37
+ if (this.translations[normalizedLang]) {
38
+ return normalizedLang;
39
+ }
40
+
41
+ if (this.loadingPromises.has(normalizedLang)) {
42
+ await this.loadingPromises.get(normalizedLang);
43
+ return this.translations[normalizedLang] ? normalizedLang : null;
44
+ }
45
+
46
+ if (!this.builtInLanguageLoaders[normalizedLang]) {
47
+ return null;
48
+ }
49
+
50
+ const loadPromise = (async () => {
51
+ try {
52
+ const loaded = await loadBuiltInTranslation(normalizedLang);
53
+ if (loaded) {
54
+ this.translations[normalizedLang] = loaded;
55
+ }
56
+ } catch (error) {
57
+ console.warn(`Language "${normalizedLang}" failed to load:`, error);
58
+ } finally {
59
+ this.loadingPromises.delete(normalizedLang);
60
+ }
61
+ })();
62
+
63
+ this.loadingPromises.set(normalizedLang, loadPromise);
64
+ await loadPromise;
65
+
66
+ return this.translations[normalizedLang] ? normalizedLang : null;
67
+ }
68
+
27
69
  t(key, replacements = {}) {
28
70
  const keys = key.split('.');
29
71
  let value = this.translations[this.currentLanguage];
@@ -84,20 +126,22 @@ class I18n {
84
126
  const contentType = response.headers.get('content-type') || '';
85
127
  let translations;
86
128
 
129
+ const buffer = await response.arrayBuffer();
130
+ const utf8Text = new TextDecoder('utf-8').decode(buffer);
131
+
87
132
  if (contentType.includes('application/json') || url.endsWith('.json')) {
88
- translations = await response.json();
133
+ translations = JSON.parse(utf8Text);
89
134
  } else if (contentType.includes('text/yaml') || contentType.includes('application/x-yaml') || url.endsWith('.yaml') || url.endsWith('.yml')) {
90
135
  // For YAML, we'll need to parse it
91
136
  // Note: This requires a YAML parser library in production
92
137
  // For now, we'll try to parse as JSON first, then show a warning
93
- const text = await response.text();
94
138
  try {
95
139
  // Try JSON first (in case server sends JSON with YAML content-type)
96
- translations = JSON.parse(text);
140
+ translations = JSON.parse(utf8Text);
97
141
  } catch (e) {
98
142
  // If JSON parsing fails, try to use a YAML parser if available
99
143
  if (typeof window !== 'undefined' && window.jsyaml) {
100
- translations = window.jsyaml.load(text);
144
+ translations = window.jsyaml.load(utf8Text);
101
145
  } else {
102
146
  console.warn('YAML parsing requires js-yaml library. Please include it or use JSON format.');
103
147
  throw new Error('YAML parsing not available. Please use JSON format or include js-yaml library.');
@@ -105,7 +149,7 @@ class I18n {
105
149
  }
106
150
  } else {
107
151
  // Try to parse as JSON by default
108
- translations = await response.json();
152
+ translations = JSON.parse(utf8Text);
109
153
  }
110
154
 
111
155
  this.addTranslation(langCode, translations);
@@ -164,7 +164,11 @@ export const de = {
164
164
  nowPlaying: 'Läuft gerade: Titel {current} von {total}. {title}{artist}',
165
165
  by: ' von ',
166
166
  untitled: 'Ohne Titel',
167
- trackUntitled: 'Titel {number}'
167
+ trackUntitled: 'Titel {number}',
168
+ currentlyPlaying: 'Wird gerade abgespielt',
169
+ notPlaying: 'Nicht aktiv',
170
+ pressEnterPlay: 'Eingabetaste zum Abspielen',
171
+ pressEnterRestart: 'Eingabetaste zum Neustart'
168
172
  }
169
173
  };
170
174
 
@@ -164,7 +164,11 @@ export const en = {
164
164
  nowPlaying: 'Now playing: Track {current} of {total}. {title}{artist}',
165
165
  by: ' by ',
166
166
  untitled: 'Untitled',
167
- trackUntitled: 'Track {number}'
167
+ trackUntitled: 'Track {number}',
168
+ currentlyPlaying: 'Currently playing',
169
+ notPlaying: 'Not playing',
170
+ pressEnterPlay: 'Press Enter to play',
171
+ pressEnterRestart: 'Press Enter to restart'
168
172
  }
169
173
  };
170
174
 
@@ -164,7 +164,11 @@ export const es = {
164
164
  nowPlaying: 'Reproduciendo ahora: Pista {current} de {total}. {title}{artist}',
165
165
  by: ' por ',
166
166
  untitled: 'Sin título',
167
- trackUntitled: 'Pista {number}'
167
+ trackUntitled: 'Pista {number}',
168
+ currentlyPlaying: 'Reproduciendo actualmente',
169
+ notPlaying: 'Sin reproducir',
170
+ pressEnterPlay: 'Pulsa Enter para reproducir',
171
+ pressEnterRestart: 'Pulsa Enter para reiniciar'
168
172
  }
169
173
  };
170
174
 
@@ -164,7 +164,11 @@ export const fr = {
164
164
  nowPlaying: 'Lecture en cours : Piste {current} sur {total}. {title}{artist}',
165
165
  by: ' par ',
166
166
  untitled: 'Sans titre',
167
- trackUntitled: 'Piste {number}'
167
+ trackUntitled: 'Piste {number}',
168
+ currentlyPlaying: 'En cours de lecture',
169
+ notPlaying: 'Non en lecture',
170
+ pressEnterPlay: 'Appuyez sur Entrée pour lire',
171
+ pressEnterRestart: 'Appuyez sur Entrée pour recommencer'
168
172
  }
169
173
  };
170
174
 
@@ -164,7 +164,11 @@ export const ja = {
164
164
  nowPlaying: '再生中: トラック {current}/{total}. {title}{artist}',
165
165
  by: ' - ',
166
166
  untitled: 'タイトルなし',
167
- trackUntitled: 'トラック {number}'
167
+ trackUntitled: 'トラック {number}',
168
+ currentlyPlaying: '再生中',
169
+ notPlaying: '停止中',
170
+ pressEnterPlay: 'Enterキーで再生',
171
+ pressEnterRestart: 'Enterキーで最初から再生'
168
172
  }
169
173
  };
170
174
 
@@ -1,31 +1,48 @@
1
1
  /**
2
2
  * Translation strings for VidPly
3
- * This file loads all built-in language files
3
+ * Lazily loads built-in language files to keep the base bundle small.
4
4
  */
5
5
 
6
6
  import { en } from './languages/en.js';
7
- import { de } from './languages/de.js';
8
- import { es } from './languages/es.js';
9
- import { fr } from './languages/fr.js';
10
- import { ja } from './languages/ja.js';
7
+
8
+ const builtInLanguageLoaders = {
9
+ de: () => import('./languages/de.js'),
10
+ es: () => import('./languages/es.js'),
11
+ fr: () => import('./languages/fr.js'),
12
+ ja: () => import('./languages/ja.js')
13
+ };
14
+
15
+ /**
16
+ * Returns the base translations that are always available in the bundle.
17
+ * Currently this is English-only to minimize bundle size.
18
+ */
19
+ export function getBaseTranslations() {
20
+ return { en };
21
+ }
11
22
 
12
23
  /**
13
- * Load all built-in translations
14
- * @returns {Object} Object containing all built-in language translations
24
+ * Expose built-in language loaders so they can be loaded on demand.
15
25
  */
16
- export function loadBuiltInTranslations() {
17
- return {
18
- en,
19
- de,
20
- es,
21
- fr,
22
- ja
23
- };
26
+ export function getBuiltInLanguageLoaders() {
27
+ return builtInLanguageLoaders;
28
+ }
29
+
30
+ /**
31
+ * Load a single built-in language asynchronously.
32
+ * @param {string} lang Language code to load
33
+ * @returns {Promise<Object|null>} Loaded translation object or null if unavailable
34
+ */
35
+ export async function loadBuiltInTranslation(lang) {
36
+ const loader = builtInLanguageLoaders[lang];
37
+ if (!loader) return null;
38
+
39
+ const module = await loader();
40
+ return module[lang] || module.default || null;
24
41
  }
25
42
 
26
43
  /**
27
- * Legacy export for backwards compatibility
28
- * @deprecated Use loadBuiltInTranslations() instead
44
+ * Legacy export for backwards compatibility (keeps API surface stable)
45
+ * Note: Only English is included by default; other languages are loaded on demand.
29
46
  */
30
- export const translations = loadBuiltInTranslations();
47
+ export const translations = getBaseTranslations();
31
48
 
@@ -47,26 +47,12 @@ const iconPaths = {
47
47
 
48
48
  check: `<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>`,
49
49
 
50
- arrowUp: `<path d="M7 14l5-5 5 5z"/>`,
51
-
52
- arrowDown: `<path d="M7 10l5 5 5-5z"/>`,
53
-
54
- arrowLeft: `<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>`,
55
-
56
- arrowRight: `<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>`,
57
-
58
50
  loading: `<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>`,
59
51
 
60
52
  error: `<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>`,
61
53
 
62
- download: `<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>`,
63
-
64
- link: `<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>`,
65
-
66
54
  playlist: `<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>`,
67
55
 
68
- language: `<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.91 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2 0 .68.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2 0-.68.07-1.35.16-2h4.68c.09.65.16 1.32.16 2 0 .68-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2 0-.68-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/>`,
69
-
70
56
  hd: `<path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-8 12H9.5v-2h-2v2H6V9h1.5v2.5h2V9H11v6zm7-1c0 .55-.45 1-1 1h-.75v1.5h-1.5V15H14c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v4zm-3.5-.5h2v-3h-2v3z"/>`,
71
57
 
72
58
  transcript: `<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>`,
@@ -81,18 +67,14 @@ const iconPaths = {
81
67
 
82
68
  signLanguageOn: `<g transform="scale(1.5)"><path d="M16 11.3c-.1-.9-4.8 1.3-5.4 1.1-2.6-1 5.8-1.3 5.1-2.9s-5.1 1.5-6 1.4C6.5 9.4 16.5 9.1 13.5 8c-1.9-.6-8.8 2.9-6.8.4.7-.6.7-1.9-.7-1.7-9.7 7.2-.7 12.2 8.8 7 0-1.3-3.5.4-4.1.4-2.6 0 5.6-2 5.4-3ZM3.9 7.8c3.2-4.2 3.7 1.2 6 .1s.2-.2.2-.3c.7-2.7 2.5-7.5-1.5-1.3-1.6 0 1.1-4 1-4.6C8.9-1 7.3 4.4 7.2 4.9c-1.6.7-.9-1.4-.7-1.5 3-6-.6-3.1-.9.4-2.5 1.8 0-2.8 0-3.5C2.8-.9 4 9.4 1.1 4.9S.1 4.6 0 5c-.4 2.7 2.6 7.2 3.9 2.8Z"/></g>`,
83
69
 
84
- speaker: `<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>`,
85
-
86
70
  music: `<path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7zm-1.5 16c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>`,
87
71
 
88
72
  moreVertical: `<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>`,
89
73
 
90
- moreHorizontal: `<path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>`,
91
-
92
74
  move: `<path d="M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z"/>`,
93
-
75
+
94
76
  resize: `<path d="M21.71 11.29l-9-9c-.39-.39-1.02-.39-1.41 0l-9 9c-.39.39-.39 1.02 0 1.41l9 9c.39.39 1.02.39 1.41 0l9-9c.39-.38.39-1.01 0-1.41zM14 14.5V12h-4v2.5L7 11l3-3.5V10h4V7.5l3 3.5-3 3.5z"/>`,
95
-
77
+
96
78
  clock: `<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>`
97
79
  };
98
80
 
@@ -309,6 +309,13 @@ export class HLSRenderer {
309
309
  return [];
310
310
  }
311
311
 
312
+ getCurrentQuality() {
313
+ if (this.hls) {
314
+ return this.hls.currentLevel;
315
+ }
316
+ return -1;
317
+ }
318
+
312
319
  destroy() {
313
320
  if (this.hls) {
314
321
  this.hls.destroy();
@@ -489,7 +489,6 @@
489
489
  background: linear-gradient(135deg, var(--vidply-black) 0%, #2a2a2a 100%);
490
490
  height: 100%;
491
491
  order: 1; /* First in flex order */
492
- overflow: hidden;
493
492
  position: relative;
494
493
  width: 100%;
495
494
  z-index: 1; /* Base video layer */
@@ -580,6 +579,8 @@
580
579
  transform: translate(-50%, -50%);
581
580
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
582
581
  z-index: var(--vidply-z-overlay);
582
+ border: 0.125rem solid var(--vidply-primary);
583
+ border-radius: 50%;
583
584
  }
584
585
 
585
586
  .vidply-play-overlay:hover {
@@ -669,7 +670,7 @@
669
670
  .vidply-progress-time-wrapper {
670
671
  align-items: center;
671
672
  display: flex;
672
- gap: var(--vidply-gap-lg);
673
+ gap: 1.25rem;
673
674
  margin-bottom: var(--vidply-gap-lg);
674
675
  width: 100%;
675
676
  }
@@ -681,6 +682,7 @@
681
682
  cursor: pointer;
682
683
  flex: 1;
683
684
  height: 0.5625rem;
685
+ margin-right: 0.5rem;
684
686
  position: relative;
685
687
  transition: height 0.2s ease;
686
688
  }
@@ -802,6 +804,82 @@
802
804
  opacity: 0.5;
803
805
  }
804
806
 
807
+ /* Button text - hidden by default, visible when CSS is disabled */
808
+ .vidply-button-text {
809
+ display: none;
810
+ }
811
+
812
+ /* When CSS is disabled or unavailable, button text will be visible */
813
+
814
+ /* This ensures buttons are functional even without CSS */
815
+
816
+ /* Tooltip styles - aria-hidden popovers for sighted users */
817
+ .vidply-tooltip {
818
+ background: #e0e0e0;
819
+ border-radius: var(--vidply-radius-sm);
820
+ color: #000;
821
+ font-size: var(--vidply-font-xs);
822
+ left: 50%;
823
+ opacity: 0;
824
+ padding: 0.375rem 0.5rem;
825
+ pointer-events: none;
826
+ position: absolute;
827
+ top: calc(100% + 0.5rem);
828
+ transform: translateX(-50%) translateY(-0.25rem);
829
+ transition: opacity var(--vidply-transition-fast), transform var(--vidply-transition-fast);
830
+ white-space: nowrap;
831
+ z-index: calc(var(--vidply-z-menu) + 1);
832
+ }
833
+
834
+ .vidply-tooltip::before {
835
+ border-color: transparent transparent #e0e0e0;
836
+ border-style: solid;
837
+ border-width: 0 0.375rem 0.375rem;
838
+ content: '';
839
+ left: 50%;
840
+ position: absolute;
841
+ top: -0.375rem;
842
+ transform: translateX(-50%);
843
+ }
844
+
845
+ /* Show tooltip on hover/focus */
846
+ .vidply-tooltip-visible {
847
+ opacity: 1;
848
+ transform: translateX(-50%) translateY(0);
849
+ }
850
+
851
+ /* In fullscreen mode, position tooltips above buttons */
852
+ .vidply-player.vidply-fullscreen .vidply-tooltip,
853
+ .vidply-player:fullscreen .vidply-tooltip {
854
+ bottom: calc(100% + 0.5rem);
855
+ top: auto;
856
+ transform: translateX(-50%) translateY(0.25rem);
857
+ }
858
+
859
+ .vidply-player.vidply-fullscreen .vidply-tooltip-visible,
860
+ .vidply-player:fullscreen .vidply-tooltip-visible {
861
+ transform: translateX(-50%) translateY(0);
862
+ }
863
+
864
+ /* Adjust tooltip arrow for fullscreen (pointing down instead of up) */
865
+ .vidply-player.vidply-fullscreen .vidply-tooltip::before,
866
+ .vidply-player:fullscreen .vidply-tooltip::before {
867
+ border-color: #e0e0e0 transparent transparent;
868
+ border-width: 0.375rem 0.375rem 0;
869
+ bottom: -0.375rem;
870
+ top: auto;
871
+ }
872
+
873
+ /* Ensure buttons with tooltips are positioned relatively */
874
+ .vidply-button,
875
+ .vidply-icon-button,
876
+ .vidply-sign-language-settings,
877
+ .vidply-sign-language-close,
878
+ .vidply-transcript-settings,
879
+ .vidply-transcript-close {
880
+ position: relative;
881
+ }
882
+
805
883
  /* Icons */
806
884
  .vidply-icon {
807
885
  display: inline-block;
@@ -2139,14 +2217,29 @@
2139
2217
  padding: 1rem 1.25rem;
2140
2218
  }
2141
2219
 
2220
+ /* Track header - contains track number and duration */
2221
+ .vidply-track-header {
2222
+ align-items: center;
2223
+ display: flex;
2224
+ gap: 0.75rem;
2225
+ justify-content: space-between;
2226
+ margin-bottom: 0.25rem;
2227
+ }
2228
+
2142
2229
  .vidply-track-number {
2143
2230
  color: var(--vidply-text-muted);
2144
2231
  font-size: 0.75rem;
2145
2232
  letter-spacing: 0.0313rem;
2146
- margin-bottom: 0.25rem;
2147
2233
  text-transform: uppercase;
2148
2234
  }
2149
2235
 
2236
+ /* Duration in track info display */
2237
+ .vidply-track-duration {
2238
+ color: var(--vidply-text-muted);
2239
+ font-size: 0.75rem;
2240
+ font-variant-numeric: tabular-nums;
2241
+ }
2242
+
2150
2243
  .vidply-track-title {
2151
2244
  color: var(--vidply-white);
2152
2245
  font-size: 1.125rem;
@@ -2159,6 +2252,18 @@
2159
2252
  font-size: 0.875rem;
2160
2253
  }
2161
2254
 
2255
+ .vidply-track-description {
2256
+ color: var(--vidply-white-60);
2257
+ font-size: 0.8125rem;
2258
+ line-height: 1.4;
2259
+ margin-top: 0.5rem;
2260
+ max-height: 3.5em;
2261
+ overflow: hidden;
2262
+ display: -webkit-box;
2263
+ -webkit-line-clamp: 2;
2264
+ -webkit-box-orient: vertical;
2265
+ }
2266
+
2162
2267
  /* Playlist Panel */
2163
2268
  .vidply-playlist-panel {
2164
2269
  background: var(--vidply-bg-playlist);
@@ -2264,6 +2369,13 @@
2264
2369
  box-shadow: 0 0.5rem 1.5rem var(--vidply-black-60);
2265
2370
  }
2266
2371
 
2372
+ /* Fullscreen thumbnail container - takes full width of card */
2373
+ .vidply-player.vidply-fullscreen .vidply-playlist-thumbnail-container,
2374
+ .vidply-player:fullscreen .vidply-playlist-thumbnail-container {
2375
+ position: relative;
2376
+ width: 100%;
2377
+ }
2378
+
2267
2379
  .vidply-player.vidply-fullscreen .vidply-playlist-thumbnail,
2268
2380
  .vidply-player:fullscreen .vidply-playlist-thumbnail {
2269
2381
  width: 100%;
@@ -2271,11 +2383,26 @@
2271
2383
  border-radius: 0;
2272
2384
  }
2273
2385
 
2386
+ /* Larger duration badge in fullscreen for better visibility */
2387
+ .vidply-player.vidply-fullscreen .vidply-playlist-duration-badge,
2388
+ .vidply-player:fullscreen .vidply-playlist-duration-badge {
2389
+ bottom: 0.375rem;
2390
+ font-size: 0.75rem;
2391
+ padding: 0.1875rem 0.375rem;
2392
+ right: 0.375rem;
2393
+ }
2394
+
2274
2395
  .vidply-player.vidply-fullscreen .vidply-playlist-item-info,
2275
2396
  .vidply-player:fullscreen .vidply-playlist-item-info {
2276
2397
  padding: 0.75rem;
2277
2398
  }
2278
2399
 
2400
+ /* Hide description in fullscreen to save space */
2401
+ .vidply-player.vidply-fullscreen .vidply-playlist-item-description,
2402
+ .vidply-player:fullscreen .vidply-playlist-item-description {
2403
+ display: none;
2404
+ }
2405
+
2279
2406
  .vidply-player.vidply-fullscreen .vidply-playlist-item-title,
2280
2407
  .vidply-player:fullscreen .vidply-playlist-item-title {
2281
2408
  font-size: 0.875rem;
@@ -2474,6 +2601,12 @@
2474
2601
  outline-offset: -0.125rem;
2475
2602
  }
2476
2603
 
2604
+ /* Playlist Thumbnail Container (wrapper for thumbnail + duration badge) */
2605
+ .vidply-playlist-thumbnail-container {
2606
+ flex-shrink: 0;
2607
+ position: relative;
2608
+ }
2609
+
2477
2610
  /* Playlist Thumbnail */
2478
2611
  .vidply-playlist-thumbnail {
2479
2612
  align-items: center;
@@ -2505,6 +2638,23 @@
2505
2638
  color: var(--vidply-primary-light);
2506
2639
  }
2507
2640
 
2641
+ /* Duration badge on thumbnail (YouTube-style) */
2642
+ .vidply-playlist-duration-badge {
2643
+ background: rgb(0 0 0 / 80%);
2644
+ border-radius: 0.1875rem;
2645
+ bottom: 0.125rem;
2646
+ color: var(--vidply-white);
2647
+ font-family: var(--vidply-font-family);
2648
+ font-size: 0.625rem;
2649
+ font-variant-numeric: tabular-nums;
2650
+ font-weight: 500;
2651
+ letter-spacing: 0.02em;
2652
+ line-height: 1;
2653
+ padding: 0.125rem 0.25rem;
2654
+ position: absolute;
2655
+ right: 0.125rem;
2656
+ }
2657
+
2508
2658
  /* Playlist Item Info */
2509
2659
  .vidply-playlist-item-info {
2510
2660
  display: block;
@@ -2512,12 +2662,21 @@
2512
2662
  min-width: 0;
2513
2663
  }
2514
2664
 
2665
+ /* Title row - contains title and optional inline duration */
2666
+ .vidply-playlist-item-title-row {
2667
+ align-items: center;
2668
+ display: flex;
2669
+ gap: 0.5rem;
2670
+ margin-bottom: 0.25rem;
2671
+ }
2672
+
2515
2673
  .vidply-playlist-item-title {
2516
2674
  color: var(--vidply-white);
2517
2675
  display: block;
2676
+ flex: 1;
2518
2677
  font-size: 0.875rem;
2519
2678
  font-weight: 500;
2520
- margin-bottom: 0.25rem;
2679
+ min-width: 0;
2521
2680
  overflow: hidden;
2522
2681
  text-overflow: ellipsis;
2523
2682
  white-space: nowrap;
@@ -2527,6 +2686,14 @@
2527
2686
  color: var(--vidply-primary-light);
2528
2687
  }
2529
2688
 
2689
+ /* Inline duration (shown when no thumbnail) */
2690
+ .vidply-playlist-item-duration {
2691
+ color: var(--vidply-text-disabled);
2692
+ flex-shrink: 0;
2693
+ font-size: 0.6875rem;
2694
+ font-variant-numeric: tabular-nums;
2695
+ }
2696
+
2530
2697
  .vidply-playlist-item-artist {
2531
2698
  color: var(--vidply-text-disabled);
2532
2699
  display: block;
@@ -2536,6 +2703,19 @@
2536
2703
  white-space: nowrap;
2537
2704
  }
2538
2705
 
2706
+ /* Description - truncated with ellipsis */
2707
+ .vidply-playlist-item-description {
2708
+ -webkit-box-orient: vertical;
2709
+ color: var(--vidply-text-subtle);
2710
+ display: -webkit-box;
2711
+ font-size: 0.6875rem;
2712
+ -webkit-line-clamp: 2;
2713
+ line-height: 1.4;
2714
+ margin-top: 0.25rem;
2715
+ overflow: hidden;
2716
+ text-overflow: ellipsis;
2717
+ }
2718
+
2539
2719
  /* Playlist Item Icon */
2540
2720
  .vidply-playlist-item-icon {
2541
2721
  flex-shrink: 0;
@@ -149,6 +149,73 @@ export const DOMUtils = {
149
149
 
150
150
  temp.innerHTML = safeHtml;
151
151
  return temp.innerHTML;
152
+ },
153
+
154
+ /**
155
+ * Create a tooltip element that is aria-hidden (not read by screen readers)
156
+ * @param {string} text - Tooltip text
157
+ * @param {string} classPrefix - Class prefix for styling
158
+ * @returns {HTMLElement} Tooltip element
159
+ */
160
+ createTooltip(text, classPrefix = 'vidply') {
161
+ const tooltip = this.createElement('span', {
162
+ className: `${classPrefix}-tooltip`,
163
+ textContent: text,
164
+ attributes: {
165
+ 'aria-hidden': 'true'
166
+ }
167
+ });
168
+ return tooltip;
169
+ },
170
+
171
+ /**
172
+ * Attach a tooltip to an element
173
+ * @param {HTMLElement} element - Element to attach tooltip to
174
+ * @param {string} text - Tooltip text
175
+ * @param {string} classPrefix - Class prefix for styling
176
+ */
177
+ attachTooltip(element, text, classPrefix = 'vidply') {
178
+ if (!element || !text) return;
179
+
180
+ // Remove existing tooltip if any
181
+ const existingTooltip = element.querySelector(`.${classPrefix}-tooltip`);
182
+ if (existingTooltip) {
183
+ existingTooltip.remove();
184
+ }
185
+
186
+ const tooltip = this.createTooltip(text, classPrefix);
187
+ element.appendChild(tooltip);
188
+
189
+ // Show tooltip on hover/focus
190
+ const showTooltip = () => {
191
+ tooltip.classList.add(`${classPrefix}-tooltip-visible`);
192
+ };
193
+
194
+ const hideTooltip = () => {
195
+ tooltip.classList.remove(`${classPrefix}-tooltip-visible`);
196
+ };
197
+
198
+ element.addEventListener('mouseenter', showTooltip);
199
+ element.addEventListener('mouseleave', hideTooltip);
200
+ element.addEventListener('focus', showTooltip);
201
+ element.addEventListener('blur', hideTooltip);
202
+ },
203
+
204
+ /**
205
+ * Create visible button text that is hidden by CSS but visible when CSS is disabled
206
+ * @param {string} text - Button text
207
+ * @param {string} classPrefix - Class prefix for styling
208
+ * @returns {HTMLElement} Button text element
209
+ */
210
+ createButtonText(text, classPrefix = 'vidply') {
211
+ const buttonText = this.createElement('span', {
212
+ className: `${classPrefix}-button-text`,
213
+ textContent: text,
214
+ attributes: {
215
+ 'aria-hidden': 'true'
216
+ }
217
+ });
218
+ return buttonText;
152
219
  }
153
220
  };
154
221