@mtldev514/retro-portfolio-engine 1.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.
- package/README.md +408 -0
- package/bin/cli.js +103 -0
- package/engine/admin/admin.css +720 -0
- package/engine/admin/admin.html +801 -0
- package/engine/admin/admin_api.py +230 -0
- package/engine/admin/scripts/backup.sh +116 -0
- package/engine/admin/scripts/config_loader.py +180 -0
- package/engine/admin/scripts/init.sh +141 -0
- package/engine/admin/scripts/manager.py +308 -0
- package/engine/admin/scripts/restore.sh +121 -0
- package/engine/admin/scripts/server.py +41 -0
- package/engine/admin/scripts/update.sh +321 -0
- package/engine/admin/scripts/validate_json.py +62 -0
- package/engine/fonts.css +37 -0
- package/engine/index.html +190 -0
- package/engine/js/config-loader.js +370 -0
- package/engine/js/config.js +173 -0
- package/engine/js/counter.js +17 -0
- package/engine/js/effects.js +97 -0
- package/engine/js/i18n.js +68 -0
- package/engine/js/init.js +107 -0
- package/engine/js/media.js +264 -0
- package/engine/js/render.js +282 -0
- package/engine/js/router.js +133 -0
- package/engine/js/sparkle.js +123 -0
- package/engine/js/themes.js +607 -0
- package/engine/style.css +2037 -0
- package/index.js +35 -0
- package/package.json +48 -0
- package/scripts/admin.js +67 -0
- package/scripts/build.js +142 -0
- package/scripts/init.js +237 -0
- package/scripts/post-install.js +16 -0
- package/scripts/serve.js +54 -0
- package/templates/user-portfolio/.github/workflows/deploy.yml +57 -0
- package/templates/user-portfolio/config/app.json +36 -0
- package/templates/user-portfolio/config/categories.json +241 -0
- package/templates/user-portfolio/config/languages.json +15 -0
- package/templates/user-portfolio/config/media-types.json +59 -0
- package/templates/user-portfolio/data/painting.json +3 -0
- package/templates/user-portfolio/data/projects.json +3 -0
- package/templates/user-portfolio/lang/en.json +114 -0
- package/templates/user-portfolio/lang/fr.json +114 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const i18n = {
|
|
2
|
+
currentLang: localStorage.getItem('selectedLang') || (window.AppConfig?.getDefaultLanguage() || 'en'),
|
|
3
|
+
translations: {},
|
|
4
|
+
|
|
5
|
+
async init() {
|
|
6
|
+
// Wait for config to load if not already loaded
|
|
7
|
+
if (window.AppConfig && !window.AppConfig.loaded) {
|
|
8
|
+
await window.AppConfig.load();
|
|
9
|
+
}
|
|
10
|
+
await this.loadTranslations(this.currentLang);
|
|
11
|
+
this.updateDOM();
|
|
12
|
+
this.updateSwitcherUI();
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async loadTranslations(lang) {
|
|
16
|
+
try {
|
|
17
|
+
const langDir = window.AppConfig?.getSetting('paths.langDir') || 'lang';
|
|
18
|
+
const response = await fetch(`${langDir}/${lang}.json`);
|
|
19
|
+
if (!response.ok) throw new Error(`Could not load ${lang} translation`);
|
|
20
|
+
this.translations = await response.ok ? await response.json() : {};
|
|
21
|
+
this.currentLang = lang;
|
|
22
|
+
localStorage.setItem('selectedLang', lang);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('i18n Error:', error);
|
|
25
|
+
// Fallback to default language
|
|
26
|
+
const defaultLang = window.AppConfig?.getDefaultLanguage() || 'en';
|
|
27
|
+
if (lang !== defaultLang) await this.loadTranslations(defaultLang);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
updateDOM() {
|
|
32
|
+
const elements = document.querySelectorAll('[data-i18n]');
|
|
33
|
+
elements.forEach(el => {
|
|
34
|
+
const key = el.getAttribute('data-i18n');
|
|
35
|
+
if (this.translations[key]) {
|
|
36
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
37
|
+
el.placeholder = this.translations[key];
|
|
38
|
+
} else {
|
|
39
|
+
el.innerText = this.translations[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
updateSwitcherUI() {
|
|
46
|
+
// Gear button is static — no UI update needed
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async changeLang(lang) {
|
|
50
|
+
await this.loadTranslations(lang);
|
|
51
|
+
this.updateSwitcherUI();
|
|
52
|
+
// Rebuild media playlist names for new language
|
|
53
|
+
if (window.media && media.rawTracks.length > 0) {
|
|
54
|
+
media.buildPlaylist();
|
|
55
|
+
media.updateTrackDisplay();
|
|
56
|
+
media.populateTrackSelector();
|
|
57
|
+
}
|
|
58
|
+
// Re-load current page to re-render everything in the new language
|
|
59
|
+
if (window.router && router.currentRoute) {
|
|
60
|
+
await router.loadPage(window.location.pathname + window.location.search);
|
|
61
|
+
} else {
|
|
62
|
+
this.updateDOM();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// i18n.init() is called by the router during boot — no auto-run needed
|
|
68
|
+
window.i18n = i18n;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application Initializer
|
|
3
|
+
* Loads configuration and initializes all modules
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(async function() {
|
|
7
|
+
console.log('🚀 Initializing application...');
|
|
8
|
+
|
|
9
|
+
// Load configuration first
|
|
10
|
+
const configLoaded = await AppConfig.load();
|
|
11
|
+
if (!configLoaded) {
|
|
12
|
+
console.error('Failed to load configuration. Using defaults.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Initialize language selector dynamically
|
|
16
|
+
initLanguageSelector();
|
|
17
|
+
|
|
18
|
+
// Initialize theme selector dynamically
|
|
19
|
+
initThemeSelector();
|
|
20
|
+
|
|
21
|
+
// Initialize other modules
|
|
22
|
+
if (typeof i18n !== 'undefined') {
|
|
23
|
+
i18n.init();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof themes !== 'undefined') {
|
|
27
|
+
themes.init();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log('✅ Application initialized');
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dynamically build language selector from config
|
|
35
|
+
*/
|
|
36
|
+
function initLanguageSelector() {
|
|
37
|
+
const langSection = document.querySelector('.settings-dropdown .settings-section-label');
|
|
38
|
+
if (!langSection || langSection.textContent !== 'Language') {
|
|
39
|
+
return; // Not on a page with language selector
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Find the language options container
|
|
43
|
+
const settingsDropdown = langSection.parentElement;
|
|
44
|
+
const langDividerIndex = Array.from(settingsDropdown.children).indexOf(langSection) - 1;
|
|
45
|
+
|
|
46
|
+
// Remove existing language options
|
|
47
|
+
const existingLangOptions = [];
|
|
48
|
+
let sibling = langSection.nextElementSibling;
|
|
49
|
+
while (sibling && !sibling.classList.contains('settings-divider') && !sibling.classList.contains('settings-section-label')) {
|
|
50
|
+
existingLangOptions.push(sibling);
|
|
51
|
+
sibling = sibling.nextElementSibling;
|
|
52
|
+
}
|
|
53
|
+
existingLangOptions.forEach(el => el.remove());
|
|
54
|
+
|
|
55
|
+
// Add new language options from config
|
|
56
|
+
if (AppConfig.languages && AppConfig.languages.supportedLanguages) {
|
|
57
|
+
AppConfig.languages.supportedLanguages.forEach(lang => {
|
|
58
|
+
const option = document.createElement('div');
|
|
59
|
+
option.className = 'settings-option';
|
|
60
|
+
option.onclick = () => i18n.changeLang(lang.code);
|
|
61
|
+
option.innerHTML = `<span class="lang-flag">${lang.flag}</span> ${lang.name}`;
|
|
62
|
+
|
|
63
|
+
// Insert after language label
|
|
64
|
+
langSection.parentNode.insertBefore(option, langSection.nextSibling);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dynamically build theme selector from config
|
|
71
|
+
*/
|
|
72
|
+
function initThemeSelector() {
|
|
73
|
+
// This will be implemented when we externalize themes.js
|
|
74
|
+
// For now, themes are still in themes.js
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Dynamically build category filters from config
|
|
79
|
+
*/
|
|
80
|
+
function initCategoryFilters() {
|
|
81
|
+
const filterButtons = document.querySelector('.filter-buttons');
|
|
82
|
+
if (!filterButtons || !AppConfig.categories) return;
|
|
83
|
+
|
|
84
|
+
// Clear existing filters except "All"
|
|
85
|
+
const allButton = filterButtons.querySelector('[data-filter="all"]');
|
|
86
|
+
filterButtons.innerHTML = '';
|
|
87
|
+
if (allButton) {
|
|
88
|
+
filterButtons.appendChild(allButton);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Add category filters from config
|
|
92
|
+
AppConfig.getAllCategories().forEach(category => {
|
|
93
|
+
const button = document.createElement('button');
|
|
94
|
+
button.className = 'filter-btn';
|
|
95
|
+
button.setAttribute('data-filter', category.id);
|
|
96
|
+
button.textContent = `${category.icon} ${category.name}`;
|
|
97
|
+
button.onclick = () => {
|
|
98
|
+
if (typeof render !== 'undefined' && render.filterCategory) {
|
|
99
|
+
render.filterCategory(category.id);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
filterButtons.appendChild(button);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Export for use in other modules
|
|
107
|
+
window.initCategoryFilters = initCategoryFilters;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Winamp-style Media Controller for Alex's Portfolio
|
|
3
|
+
* Manages audio playlist with seek, time display, and mini visualizer
|
|
4
|
+
* Loads tracks dynamically from data/music.json
|
|
5
|
+
*/
|
|
6
|
+
const media = {
|
|
7
|
+
audio: new Audio(),
|
|
8
|
+
currentTrackIndex: 0,
|
|
9
|
+
playlist: [],
|
|
10
|
+
rawTracks: [],
|
|
11
|
+
vizInterval: null,
|
|
12
|
+
|
|
13
|
+
tf(field) {
|
|
14
|
+
if (!field) return '';
|
|
15
|
+
if (typeof field === 'object' && !Array.isArray(field)) {
|
|
16
|
+
const lang = (window.i18n && i18n.currentLang) || 'en';
|
|
17
|
+
return field[lang] || field.en || '';
|
|
18
|
+
}
|
|
19
|
+
return field;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async init() {
|
|
23
|
+
await this.loadPlaylist();
|
|
24
|
+
this.setupEventListeners();
|
|
25
|
+
this.updateTrackDisplay();
|
|
26
|
+
this.populateTrackSelector();
|
|
27
|
+
this.startViz();
|
|
28
|
+
console.log(`Winamp initialized with ${this.playlist.length} tracks`);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
buildPlaylist() {
|
|
32
|
+
this.playlist = this.rawTracks.map(t => {
|
|
33
|
+
const title = this.tf(t.title);
|
|
34
|
+
const genre = this.tf(t.genre);
|
|
35
|
+
return {
|
|
36
|
+
name: title + (genre ? ` [${genre}]` : ''),
|
|
37
|
+
src: t.url
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async loadPlaylist() {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch('data/music.json');
|
|
45
|
+
const tracks = await res.json();
|
|
46
|
+
if (tracks && tracks.length > 0) {
|
|
47
|
+
this.rawTracks = tracks;
|
|
48
|
+
this.buildPlaylist();
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.log('Could not load music.json');
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
populateTrackSelector() {
|
|
56
|
+
const list = document.getElementById('radio-tracklist');
|
|
57
|
+
if (!list) return;
|
|
58
|
+
list.innerHTML = '';
|
|
59
|
+
if (this.playlist.length === 0) {
|
|
60
|
+
const empty = document.createElement('div');
|
|
61
|
+
empty.className = 'winamp-pl-item winamp-pl-empty';
|
|
62
|
+
empty.textContent = (window.i18n && i18n.translations.sidebar_radio_no_tracks) || 'No tracks available';
|
|
63
|
+
list.appendChild(empty);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.playlist.forEach((track, i) => {
|
|
67
|
+
const item = document.createElement('div');
|
|
68
|
+
item.className = 'winamp-pl-item';
|
|
69
|
+
if (i === this.currentTrackIndex) item.classList.add('active');
|
|
70
|
+
item.dataset.index = i;
|
|
71
|
+
item.innerHTML = `<span class="winamp-pl-num">${i + 1}.</span> ${track.name}`;
|
|
72
|
+
item.ondblclick = () => this.switchTrack(i);
|
|
73
|
+
item.onclick = () => {
|
|
74
|
+
// Single click = select, highlight
|
|
75
|
+
list.querySelectorAll('.winamp-pl-item.selected').forEach(el => el.classList.remove('selected'));
|
|
76
|
+
item.classList.add('selected');
|
|
77
|
+
};
|
|
78
|
+
list.appendChild(item);
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
setupEventListeners() {
|
|
83
|
+
const playPauseBtn = document.querySelector('.radio-playpause');
|
|
84
|
+
const prevBtn = document.querySelector('.radio-prev');
|
|
85
|
+
const nextBtn = document.querySelector('.radio-next');
|
|
86
|
+
const volumeSlider = document.querySelector('.radio-volume');
|
|
87
|
+
const seekBar = document.getElementById('winamp-seek');
|
|
88
|
+
|
|
89
|
+
if (playPauseBtn) playPauseBtn.onclick = () => this.togglePlayPause();
|
|
90
|
+
if (prevBtn) prevBtn.onclick = () => this.prev();
|
|
91
|
+
if (nextBtn) nextBtn.onclick = () => this.next();
|
|
92
|
+
|
|
93
|
+
if (volumeSlider) {
|
|
94
|
+
volumeSlider.oninput = (e) => {
|
|
95
|
+
this.audio.volume = e.target.value / 100;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Seek bar
|
|
100
|
+
if (seekBar) {
|
|
101
|
+
seekBar.oninput = (e) => {
|
|
102
|
+
if (this.audio.duration) {
|
|
103
|
+
this.audio.currentTime = (e.target.value / 100) * this.audio.duration;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Update time, duration, and seek position
|
|
109
|
+
this.audio.ontimeupdate = () => {
|
|
110
|
+
const timeEl = document.getElementById('winamp-time');
|
|
111
|
+
if (timeEl) {
|
|
112
|
+
const m = Math.floor(this.audio.currentTime / 60);
|
|
113
|
+
const s = Math.floor(this.audio.currentTime % 60);
|
|
114
|
+
timeEl.textContent = String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
|
|
115
|
+
}
|
|
116
|
+
const durEl = document.getElementById('winamp-duration');
|
|
117
|
+
if (durEl && this.audio.duration) {
|
|
118
|
+
const dm = Math.floor(this.audio.duration / 60);
|
|
119
|
+
const ds = Math.floor(this.audio.duration % 60);
|
|
120
|
+
durEl.textContent = String(dm).padStart(2, '0') + ':' + String(ds).padStart(2, '0');
|
|
121
|
+
}
|
|
122
|
+
const seek = document.getElementById('winamp-seek');
|
|
123
|
+
if (seek && this.audio.duration) {
|
|
124
|
+
seek.value = (this.audio.currentTime / this.audio.duration) * 100;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Sync playing class and button icon with actual audio state
|
|
129
|
+
this.audio.onplay = () => {
|
|
130
|
+
this.setPlayingState(true);
|
|
131
|
+
this.updatePlayPauseIcon(true);
|
|
132
|
+
};
|
|
133
|
+
this.audio.onpause = () => {
|
|
134
|
+
this.setPlayingState(false);
|
|
135
|
+
this.updatePlayPauseIcon(false);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Auto-advance to next track
|
|
139
|
+
this.audio.onended = () => this.next();
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
setPlayingState(isPlaying) {
|
|
143
|
+
const winamp = document.querySelector('.winamp');
|
|
144
|
+
if (winamp) winamp.classList.toggle('playing', isPlaying);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
togglePlayPause() {
|
|
148
|
+
if (this.audio.paused) {
|
|
149
|
+
this.play();
|
|
150
|
+
} else {
|
|
151
|
+
this.audio.pause();
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
play() {
|
|
156
|
+
if (!this.audio.src && this.playlist.length > 0) this.switchTrack(0);
|
|
157
|
+
const p = this.audio.play();
|
|
158
|
+
if (p && p.catch) p.catch(e => console.warn('Play blocked:', e));
|
|
159
|
+
this.setPlayingState(true);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
updatePlayPauseIcon(isPlaying) {
|
|
163
|
+
const btn = document.querySelector('.radio-playpause');
|
|
164
|
+
if (!btn) return;
|
|
165
|
+
const svg = btn.querySelector('svg');
|
|
166
|
+
if (!svg) return;
|
|
167
|
+
const playPath = 'M8 5v14l11-7z';
|
|
168
|
+
const pausePath = 'M6 19h4V5H6v14zm8-14v14h4V5h-4z';
|
|
169
|
+
svg.querySelector('path').setAttribute('d', isPlaying ? pausePath : playPath);
|
|
170
|
+
btn.title = isPlaying ? 'Pause' : 'Play';
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
prev() {
|
|
174
|
+
if (this.playlist.length === 0) return;
|
|
175
|
+
const prev = (this.currentTrackIndex - 1 + this.playlist.length) % this.playlist.length;
|
|
176
|
+
this.switchTrack(prev);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
next() {
|
|
180
|
+
if (this.playlist.length === 0) return;
|
|
181
|
+
const next = (this.currentTrackIndex + 1) % this.playlist.length;
|
|
182
|
+
this.switchTrack(next);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
switchTrack(index) {
|
|
186
|
+
this.currentTrackIndex = index;
|
|
187
|
+
const track = this.playlist[index];
|
|
188
|
+
if (!track) return;
|
|
189
|
+
this.audio.src = track.src;
|
|
190
|
+
this.updateTrackDisplay();
|
|
191
|
+
this.play();
|
|
192
|
+
// Highlight active track in playlist
|
|
193
|
+
const list = document.getElementById('radio-tracklist');
|
|
194
|
+
if (list) {
|
|
195
|
+
list.querySelectorAll('.winamp-pl-item').forEach(el => {
|
|
196
|
+
el.classList.toggle('active', parseInt(el.dataset.index) === index);
|
|
197
|
+
});
|
|
198
|
+
// Scroll active into view
|
|
199
|
+
const activeEl = list.querySelector('.winamp-pl-item.active');
|
|
200
|
+
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
updateTrackDisplay() {
|
|
205
|
+
const el = document.querySelector('.radio-track-name');
|
|
206
|
+
if (!el) return;
|
|
207
|
+
if (this.playlist.length > 0) {
|
|
208
|
+
const idx = this.currentTrackIndex + 1;
|
|
209
|
+
el.innerText = `${idx}. ${this.playlist[this.currentTrackIndex].name}`;
|
|
210
|
+
} else {
|
|
211
|
+
el.innerText = (window.i18n && i18n.translations.sidebar_radio_no_tracks) || 'No tracks available';
|
|
212
|
+
}
|
|
213
|
+
this.setupTickerScroll(el);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
setupTickerScroll(el) {
|
|
217
|
+
if (!el) el = document.querySelector('.radio-track-name');
|
|
218
|
+
if (!el) return;
|
|
219
|
+
const container = el.parentElement;
|
|
220
|
+
const apply = () => {
|
|
221
|
+
if (!container.clientWidth) return;
|
|
222
|
+
const totalDist = container.clientWidth + el.scrollWidth;
|
|
223
|
+
const speed = Math.max(6, totalDist / 40);
|
|
224
|
+
el.style.setProperty('--ticker-speed', `${speed}s`);
|
|
225
|
+
el.style.setProperty('--ticker-end', `-${el.scrollWidth}px`);
|
|
226
|
+
};
|
|
227
|
+
apply();
|
|
228
|
+
setTimeout(apply, 200);
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Mini fake visualizer (random bars, Winamp style)
|
|
232
|
+
startViz() {
|
|
233
|
+
const viz = document.getElementById('winamp-viz');
|
|
234
|
+
if (!viz) return;
|
|
235
|
+
|
|
236
|
+
// Create 12 bars
|
|
237
|
+
viz.innerHTML = '';
|
|
238
|
+
for (let i = 0; i < 12; i++) {
|
|
239
|
+
const bar = document.createElement('div');
|
|
240
|
+
bar.className = 'winamp-viz-bar';
|
|
241
|
+
viz.appendChild(bar);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const bars = viz.querySelectorAll('.winamp-viz-bar');
|
|
245
|
+
|
|
246
|
+
// Animate based on play state
|
|
247
|
+
const animate = () => {
|
|
248
|
+
bars.forEach(bar => {
|
|
249
|
+
let h;
|
|
250
|
+
if (!this.audio.paused && this.audio.src) {
|
|
251
|
+
h = Math.random() * 100;
|
|
252
|
+
} else {
|
|
253
|
+
h = 3;
|
|
254
|
+
}
|
|
255
|
+
bar.style.height = h + '%';
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
this.vizInterval = setInterval(animate, 120);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// media.init() is called by the router during boot — no auto-run needed
|
|
264
|
+
window.media = media;
|