a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript

feat: init

+2920
+1
.envrc
··· 1 + use nix
+3
.gitignore
··· 1 + .direnv 2 + .DS_Store 3 + node_modules
+25
.tangled/workflows/wisp-deploy.yml
··· 1 + when: 2 + - event: ["push"] 3 + branch: ["main"] 4 + 5 + engine: "nixery" 6 + dependencies: 7 + nixpkgs: 8 + - nodejs 9 + - coreutils 10 + - curl 11 + - glibc 12 + - pnpm 13 + 14 + environment: 15 + SITE_PATH: "src" 16 + SITE_NAME: "tinysub" 17 + WISP_HANDLE: "devins.page" 18 + 19 + steps: 20 + - name: deploy 21 + command: | 22 + pnpm wispctl deploy "$WISP_HANDLE" \ 23 + --path "$SITE_PATH" \ 24 + --site "$SITE_NAME" \ 25 + --password "$WISP_APP_PASSWORD"
+11
package.json
··· 1 + { 2 + "name": "tinysub", 3 + "version": "1.0.0", 4 + "dependencies": { 5 + "prettier": "^3.8.1", 6 + "wispctl": "^1.0.10" 7 + }, 8 + "prettier": { 9 + "useTabs": true 10 + } 11 + }
+36
pnpm-lock.yaml
··· 1 + lockfileVersion: "9.0" 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + .: 9 + dependencies: 10 + prettier: 11 + specifier: ^3.8.1 12 + version: 3.8.1 13 + wispctl: 14 + specifier: ^1.0.10 15 + version: 1.0.10 16 + 17 + packages: 18 + prettier@3.8.1: 19 + resolution: 20 + { 21 + integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==, 22 + } 23 + engines: { node: ">=14" } 24 + hasBin: true 25 + 26 + wispctl@1.0.10: 27 + resolution: 28 + { 29 + integrity: sha512-yn8yBU/9qMU252Vf8n97a26k0BPsMDqoTrUm4hOLHD8QJ6z9HheuZJtlqeNuMbY9L8NBCZw7vf4xRGjt0xDt4w==, 30 + } 31 + hasBin: true 32 + 33 + snapshots: 34 + prettier@3.8.1: {} 35 + 36 + wispctl@1.0.10: {}
+7
shell.nix
··· 1 + {pkgs ? import <nixpkgs> {}}: 2 + pkgs.mkShell { 3 + buildInputs = with pkgs; [ 4 + nodejs 5 + pnpm 6 + ]; 7 + }
+57
src/css/base.css
··· 1 + /* var */ 2 + :root { 3 + color-scheme: light dark; 4 + 5 + /* borders */ 6 + --border: light-dark(hsl(0 0% 0% / 0.25), hsl(0 0% 100% / 0.25)); 7 + --border-subtle: light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 100% / 0.1)); 8 + 9 + /* backgrounds */ 10 + --bg-secondary: light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 100% / 0.05)); 11 + --bg-tertiary: light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 100% / 0.1)); 12 + --bg-context-menu: light-dark(hsl(0 0% 100% / 0.75), hsl(0 0% 10% / 0.75)); 13 + 14 + /* state colors */ 15 + --playing: hsl(200 90% 50% / 0.25); 16 + --playing-pulse: hsl(200 90% 50% / 0.5); 17 + --selection-color: hsl(220 80% 45%); 18 + --error-color: hsl(0 80% 50%); 19 + 20 + /* cover art sizing (customize via settings) */ 21 + --icon: 16px; 22 + --art-album: 32px; 23 + --art-artist: 16px; 24 + --art-song: 16px; 25 + } 26 + 27 + /* resets */ 28 + * { 29 + margin: 0; 30 + padding: 0; 31 + } 32 + 33 + /* document stuff */ 34 + html { 35 + font-size: 0.8125rem; 36 + } 37 + 38 + body { 39 + font-family: system-ui, sans-serif; 40 + overflow: hidden; 41 + user-select: none; 42 + -webkit-user-select: none; 43 + } 44 + 45 + /* interactive elements */ 46 + a, 47 + button, 48 + [role="button"] { 49 + cursor: default; 50 + } 51 + 52 + /* responsive sizing for mobile */ 53 + @media (max-width: 768px) { 54 + html { 55 + font-size: 1rem; 56 + } 57 + }
+354
src/css/components.css
··· 1 + /* button base styling (all buttons use flex for icon + text alignment) */ 2 + button { 3 + background: none; 4 + border: none; 5 + padding: 0; 6 + display: inline-flex; 7 + align-items: center; 8 + gap: 0.5rem; 9 + color: inherit; 10 + font: inherit; 11 + } 12 + 13 + /* pulsing animation for currently playing track */ 14 + @keyframes pulse { 15 + 0%, 16 + 100% { 17 + background-color: var(--playing); 18 + } 19 + 50% { 20 + background-color: var(--playing-pulse); 21 + } 22 + } 23 + 24 + /* sidebar library section headings */ 25 + #sidebar h2 { 26 + font-size: 1rem; 27 + margin-top: 0.5rem; 28 + } 29 + 30 + #sidebar h2 a { 31 + color: inherit; 32 + text-decoration: none; 33 + } 34 + 35 + #sidebar ul { 36 + list-style: none; 37 + } 38 + 39 + #sidebar li { 40 + margin-top: 0.5rem; 41 + } 42 + 43 + /* tree item container (expand button + name + actions) */ 44 + .tree-item { 45 + display: flex; 46 + align-items: center; 47 + gap: 0.5rem; 48 + } 49 + 50 + /* toggle button and item name (with art and text) */ 51 + .tree-toggle, 52 + .tree-name { 53 + color: inherit; 54 + text-decoration: none; 55 + flex: 1; 56 + display: flex; 57 + gap: 0.5rem; 58 + align-items: center; 59 + min-width: 0; 60 + } 61 + 62 + /* ellipsis text overflow for item names */ 63 + .tree-toggle span, 64 + .tree-name span { 65 + overflow: hidden; 66 + text-overflow: ellipsis; 67 + white-space: nowrap; 68 + } 69 + 70 + /* disabled state for action buttons */ 71 + .tree-action.disabled { 72 + opacity: 0.5; 73 + } 74 + 75 + /* artist-level art icons */ 76 + .tree-toggle img, 77 + .tree-name img { 78 + width: var(--art-artist); 79 + height: var(--art-artist); 80 + aspect-ratio: 1 / 1; 81 + object-fit: cover; 82 + } 83 + 84 + /* album-level art icons (nested under artists) */ 85 + ul.nested > li .tree-toggle img, 86 + ul.nested > li .tree-name img { 87 + width: var(--art-album); 88 + height: var(--art-album); 89 + } 90 + 91 + /* song art in queue table */ 92 + .queue-cover { 93 + width: var(--art-song); 94 + height: var(--art-song); 95 + aspect-ratio: 1 / 1; 96 + object-fit: cover; 97 + flex-shrink: 0; 98 + } 99 + 100 + /* favorite button toggle states */ 101 + .queue-favorite { 102 + opacity: 0.25; 103 + } 104 + 105 + .queue-favorite:hover { 106 + opacity: 0.7; 107 + } 108 + 109 + .queue-favorite.favorited { 110 + opacity: 1; 111 + } 112 + 113 + /* nested tree lists */ 114 + .nested { 115 + list-style: none; 116 + margin-left: 1rem; 117 + } 118 + 119 + .nested li { 120 + margin-top: 0.25rem; 121 + } 122 + 123 + /* currently playing track info panel */ 124 + #now-playing { 125 + display: flex; 126 + flex-direction: column; 127 + align-items: center; 128 + gap: 0.75rem; 129 + padding: 1rem 0; 130 + border-top: 1px solid var(--border-subtle); 131 + flex-shrink: 0; 132 + } 133 + 134 + #cover-art { 135 + width: 128px; 136 + height: 128px; 137 + object-fit: cover; 138 + } 139 + 140 + #track-info { 141 + text-align: center; 142 + width: 100%; 143 + } 144 + 145 + #track-title { 146 + font-weight: bold; 147 + overflow: hidden; 148 + text-overflow: ellipsis; 149 + white-space: nowrap; 150 + } 151 + 152 + #track-artist { 153 + font-size: 0.8rem; 154 + opacity: 0.75; 155 + overflow: hidden; 156 + text-overflow: ellipsis; 157 + white-space: nowrap; 158 + } 159 + 160 + #track-lyric { 161 + font-size: 0.8rem; 162 + opacity: 0.75; 163 + } 164 + 165 + /* progress slider and shuffle button alignment */ 166 + #progress { 167 + flex: 1; 168 + } 169 + 170 + #shuffle-btn { 171 + margin-left: auto; 172 + } 173 + 174 + /* queue table container */ 175 + table { 176 + width: 100%; 177 + border-collapse: collapse; 178 + table-layout: fixed; 179 + margin-top: 0.5rem; 180 + } 181 + 182 + /* hint optimization for virtual scroll updates */ 183 + tbody { 184 + will-change: contents; 185 + } 186 + 187 + th, 188 + td { 189 + text-align: left; 190 + vertical-align: middle; 191 + overflow: hidden; 192 + text-overflow: ellipsis; 193 + white-space: nowrap; 194 + } 195 + 196 + /* actions column (6th) in queue table */ 197 + #queue-table td:nth-child(6) { 198 + overflow: visible; 199 + white-space: normal; 200 + } 201 + 202 + #queue-table td:nth-child(6) button { 203 + margin-right: 0.5rem; 204 + } 205 + 206 + #queue-table td:nth-child(6) button:last-child { 207 + margin-right: 0; 208 + } 209 + 210 + /* alternating row backgrounds */ 211 + tbody tr.stripe { 212 + background: var(--bg-secondary); 213 + } 214 + 215 + /* currently playing track highlighting with pulse animation */ 216 + tbody tr.currently-playing { 217 + background: var(--playing); 218 + animation: pulse 4s linear infinite; 219 + } 220 + 221 + /* cover art column width */ 222 + #queue-table th:nth-child(1), 223 + #queue-table td:nth-child(1) { 224 + width: calc(var(--art-song) * 2); 225 + } 226 + 227 + /* drag and drop states */ 228 + tbody tr.dragging { 229 + opacity: 0.5; 230 + } 231 + 232 + tbody tr.selected { 233 + background: var(--selection-color); 234 + color: white; 235 + } 236 + 237 + tbody tr.drag-over-above { 238 + border-top: 2px solid currentColor; 239 + background: var(--bg-secondary); 240 + } 241 + 242 + tbody tr.drag-over-below { 243 + border-bottom: 2px solid currentColor; 244 + background: var(--bg-secondary); 245 + } 246 + 247 + /* floating context menu for queue actions */ 248 + .context-menu { 249 + position: fixed; 250 + background: var(--bg-context-menu); 251 + backdrop-filter: blur(16px); 252 + border: 1px solid var(--border); 253 + border-radius: 0.25rem; 254 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); 255 + z-index: 1000; 256 + min-width: 10rem; 257 + } 258 + 259 + .context-menu-item { 260 + display: block; 261 + width: 100%; 262 + padding: 0.25rem 1rem; 263 + background: none; 264 + border: none; 265 + text-align: left; 266 + } 267 + 268 + .context-menu-item:hover { 269 + background: Highlight; 270 + color: HighlightText; 271 + } 272 + 273 + /* error text color */ 274 + .error { 275 + color: var(--error-color); 276 + } 277 + 278 + /* modals (login, settings) */ 279 + .modal { 280 + position: fixed; 281 + inset: 0; 282 + background: rgba(0, 0, 0, 0.75); 283 + display: flex; 284 + align-items: center; 285 + justify-content: center; 286 + z-index: 2000; 287 + } 288 + 289 + .modal.hidden { 290 + display: none; 291 + } 292 + 293 + .modal-content { 294 + background: Menu; 295 + border: 1px solid var(--border); 296 + padding: 1rem; 297 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); 298 + } 299 + 300 + .modal-content h2 { 301 + margin-bottom: 1rem; 302 + } 303 + 304 + .modal-content button { 305 + margin-top: 1rem; 306 + padding: 0.5rem 1rem; 307 + border: 1px solid var(--border); 308 + border-radius: 0.25rem; 309 + background: var(--bg-tertiary); 310 + } 311 + 312 + /* form and settings groups */ 313 + .form-group, 314 + .settings-group { 315 + margin-bottom: 1.5rem; 316 + } 317 + 318 + .form-group label, 319 + .settings-group label { 320 + display: block; 321 + margin-bottom: 0.5rem; 322 + } 323 + 324 + .settings-group label { 325 + display: flex; 326 + align-items: center; 327 + gap: 0.5rem; 328 + } 329 + 330 + /* range sliders and checkboxes in settings */ 331 + .settings-group input[type="range"], 332 + .settings-group input[type="checkbox"] { 333 + width: 100%; 334 + cursor: pointer; 335 + } 336 + 337 + .settings-group input[type="checkbox"] { 338 + width: auto; 339 + } 340 + 341 + #close-settings-btn { 342 + width: 100%; 343 + padding: 0.5rem; 344 + margin-top: 1rem; 345 + background: var(--bg-tertiary); 346 + border: 1px solid var(--border); 347 + border-radius: 0.25rem; 348 + cursor: pointer; 349 + } 350 + 351 + #close-settings-btn:hover { 352 + background: Highlight; 353 + color: HighlightText; 354 + }
+99
src/css/layout.css
··· 1 + /* main layout grid (desktop: 3 columns, 3 rows) */ 2 + body { 3 + height: 100vh; 4 + display: grid; 5 + grid-template-columns: 20rem 1fr; 6 + grid-template-rows: 1fr auto auto; 7 + grid-template-areas: 8 + "sidebar main" 9 + "header header" 10 + "footer footer"; 11 + } 12 + 13 + /* playback controls (prev, play, next, progress, time display) */ 14 + header { 15 + grid-area: header; 16 + display: flex; 17 + align-items: center; 18 + justify-content: center; 19 + gap: 0.5rem; 20 + padding: 0.5rem; 21 + border-top: 1px solid var(--border); 22 + background: var(--bg-tertiary); 23 + } 24 + 25 + /* library browser and track info panel */ 26 + #sidebar { 27 + grid-area: sidebar; 28 + border-right: 1px solid var(--border); 29 + background: var(--bg-secondary); 30 + display: flex; 31 + flex-direction: column; 32 + min-height: 0; 33 + } 34 + 35 + /* container for tree of artists and playlists */ 36 + #library { 37 + flex: 1; 38 + overflow-y: auto; 39 + min-height: 0; 40 + padding-inline: 1rem; 41 + } 42 + 43 + /* song queue table */ 44 + main { 45 + grid-area: main; 46 + overflow-y: auto; 47 + padding-inline: 1rem; 48 + -webkit-overflow-scrolling: touch; 49 + } 50 + 51 + /* action buttons and version info */ 52 + footer { 53 + grid-area: footer; 54 + display: flex; 55 + align-items: center; 56 + gap: 1rem; 57 + padding: 0.5rem 1rem; 58 + border-top: 1px solid var(--border-subtle); 59 + background: var(--bg-tertiary); 60 + } 61 + 62 + /* mobile layout */ 63 + @media (max-width: 768px) { 64 + body { 65 + grid-template-columns: 1fr; 66 + grid-template-rows: 1fr 1fr auto auto; 67 + grid-template-areas: 68 + "main" 69 + "sidebar" 70 + "header" 71 + "footer"; 72 + } 73 + 74 + /* hide less important columns on mobile */ 75 + th:nth-child(3), 76 + th:nth-child(4), 77 + th:nth-child(5), 78 + td:nth-child(3), 79 + td:nth-child(4), 80 + td:nth-child(5) { 81 + display: none; 82 + } 83 + 84 + #sidebar { 85 + border-right: none; 86 + border-top: 1px solid var(--border); 87 + overflow: hidden; 88 + } 89 + 90 + /* hide now playing cover art on mobile */ 91 + #now-playing #cover-art { 92 + display: none; 93 + } 94 + 95 + /* hide button labels on mobile */ 96 + footer button span { 97 + display: none; 98 + } 99 + }
+196
src/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>tinysub</title> 7 + <link rel="stylesheet" href="css/base.css" /> 8 + <link rel="stylesheet" href="css/layout.css" /> 9 + <link rel="stylesheet" href="css/components.css" /> 10 + <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script> 11 + </head> 12 + <body> 13 + <!-- login and authentication --> 14 + <div id="auth-modal" class="modal"> 15 + <div class="modal-content"> 16 + <h2>tinysub</h2> 17 + <form id="login-form"> 18 + <div class="form-group"> 19 + <label for="server">server URL:</label> 20 + <input 21 + type="url" 22 + id="server" 23 + placeholder="http://localhost:4040" 24 + required 25 + /> 26 + </div> 27 + <div class="form-group"> 28 + <label for="username">username:</label> 29 + <input id="username" required /> 30 + </div> 31 + <div class="form-group"> 32 + <label for="password">password:</label> 33 + <input type="password" id="password" required /> 34 + </div> 35 + <button type="submit">connect</button> 36 + <p id="auth-error" class="error"></p> 37 + </form> 38 + </div> 39 + </div> 40 + <!-- settings panel --> 41 + <div id="settings-modal" class="modal hidden"> 42 + <div class="modal-content"> 43 + <h2>settings</h2> 44 + <div class="settings-group"> 45 + <label> 46 + <input type="checkbox" id="scrobbling-toggle" /> 47 + enable scrobbling 48 + </label> 49 + </div> 50 + <div class="settings-group"> 51 + <label 52 + >artist art size: <span id="artist-size-display">16</span>px</label 53 + > 54 + <input 55 + type="range" 56 + id="artist-size" 57 + min="0" 58 + max="256" 59 + step="4" 60 + value="16" 61 + /> 62 + </div> 63 + <div class="settings-group"> 64 + <label 65 + >album art size: <span id="album-size-display">32</span>px</label 66 + > 67 + <input 68 + type="range" 69 + id="album-size" 70 + min="0" 71 + max="256" 72 + step="4" 73 + value="32" 74 + /> 75 + </div> 76 + <div class="settings-group"> 77 + <label>song art size: <span id="song-size-display">16</span>px</label> 78 + <input 79 + type="range" 80 + id="song-size" 81 + min="0" 82 + max="256" 83 + step="4" 84 + value="16" 85 + /> 86 + </div> 87 + <button id="close-settings-btn">close</button> 88 + </div> 89 + </div> 90 + <header> 91 + <audio id="player" crossorigin="anonymous"></audio> 92 + <button id="prev-btn" aria-label="previous"> 93 + <img 94 + src="static/famfamfam-silk/control_rewind_blue.png" 95 + alt="previous" 96 + /> 97 + </button> 98 + <button id="play-btn" aria-label="play"> 99 + <img src="static/famfamfam-silk/control_play_blue.png" alt="play" /> 100 + </button> 101 + <button id="next-btn" aria-label="next"> 102 + <img 103 + src="static/famfamfam-silk/control_fastforward_blue.png" 104 + alt="next" 105 + /> 106 + </button> 107 + <input type="range" id="progress" min="0" max="100" value="0" /> 108 + <span id="time-display">0:00 / 0:00</span> 109 + </header> 110 + 111 + <aside id="sidebar"> 112 + <div id="library"> 113 + <h2> 114 + <a class="section-toggle" data-section="artists">▸ artists</a> 115 + </h2> 116 + <div id="artists-tree"></div> 117 + <h2> 118 + <a class="section-toggle" data-section="playlists">▸ playlists</a> 119 + </h2> 120 + <div id="playlists-tree"></div> 121 + </div> 122 + <div id="now-playing"> 123 + <img 124 + id="cover-art" 125 + src="static/subsonic.png" 126 + alt="cover" 127 + loading="lazy" 128 + /> 129 + <div id="track-info"> 130 + <div id="track-title">no track playing</div> 131 + <div id="track-artist"></div> 132 + <div id="track-lyric"></div> 133 + </div> 134 + </div> 135 + </aside> 136 + 137 + <main> 138 + <table id="queue-table"> 139 + <thead> 140 + <tr> 141 + <th></th> 142 + <th>title</th> 143 + <th>artist</th> 144 + <th>album</th> 145 + <th>duration</th> 146 + <th><span id="queue-count">0</span> songs</th> 147 + </tr> 148 + </thead> 149 + <tbody id="queue-list"></tbody> 150 + </table> 151 + <div id="background-cover"></div> 152 + </main> 153 + 154 + <footer> 155 + <div>tinysub 1.0.0</div> 156 + <button id="shuffle-btn" aria-label="shuffle"> 157 + <img 158 + src="static/famfamfam-silk/arrow_refresh_small.png" 159 + alt="shuffle" 160 + /> 161 + <span>shuffle all</span> 162 + </button> 163 + <button id="clear-btn" aria-label="clear"> 164 + <img src="static/famfamfam-silk/cross.png" alt="clear" /> 165 + <span>clear all</span> 166 + </button> 167 + <button id="settings-btn" aria-label="settings"> 168 + <img src="static/famfamfam-silk/cog.png" alt="settings" /> 169 + <span>settings</span> 170 + </button> 171 + <button id="logout-btn" aria-label="logout"> 172 + <img src="static/famfamfam-silk/door_out.png" alt="logout" /> 173 + <span>logout</span> 174 + </button> 175 + </footer> 176 + 177 + <script src="js/constants.js"></script> 178 + <script src="js/utils.js"></script> 179 + <script src="js/validation.js"></script> 180 + 181 + <script src="js/selection-manager.js"></script> 182 + 183 + <script src="js/api.js"></script> 184 + <script src="js/state.js"></script> 185 + <script src="js/settings.js"></script> 186 + <script src="js/queue.js"></script> 187 + <script src="js/library.js"></script> 188 + <script src="js/ui.js"></script> 189 + <script src="js/auth.js"></script> 190 + <script src="js/player.js"></script> 191 + <script src="js/draggable.js"></script> 192 + <script src="js/contextmenu.js"></script> 193 + <script src="js/lyrics.js"></script> 194 + <script src="js/events.js"></script> 195 + </body> 196 + </html>
+147
src/js/api.js
··· 1 + // subsonic api client for server communication 2 + class SubsonicAPI { 3 + constructor(serverUrl, username, password) { 4 + this.serverUrl = serverUrl.replace(/\/$/, ""); 5 + this.username = username; 6 + this.password = password; 7 + this.clientName = "tinysub"; 8 + this.apiVersion = "1.12.0"; 9 + this.requestTimeout = 30000; 10 + } 11 + 12 + // generate random salt and hashed password for auth 13 + getAuthParams() { 14 + const salt = Math.random().toString(36).substring(7); 15 + const token = CryptoJS.MD5(this.password + salt).toString(); 16 + return { 17 + u: this.username, 18 + t: token, 19 + s: salt, 20 + c: this.clientName, 21 + v: this.apiVersion, 22 + }; 23 + } 24 + 25 + // build authenticated REST API URL with params 26 + _buildAuthUrl(method, params = {}) { 27 + return `${this.serverUrl}/rest/${method}?${new URLSearchParams({ 28 + ...this.getAuthParams(), 29 + ...params, 30 + })}`; 31 + } 32 + 33 + // make request to subsonic rest api with error handling 34 + async request(method, params = {}) { 35 + if (!method || typeof method !== "string" || method.trim().length === 0) { 36 + throw new Error("API method is required"); 37 + } 38 + 39 + const queryParams = new URLSearchParams({ 40 + ...this.getAuthParams(), 41 + f: "json", 42 + ...params, 43 + }); 44 + 45 + const controller = new AbortController(); 46 + const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); 47 + 48 + try { 49 + const response = await fetch( 50 + `${this.serverUrl}/rest/${method}?${queryParams}`, 51 + { signal: controller.signal }, 52 + ); 53 + const data = await response.json(); 54 + 55 + if (data["subsonic-response"].status === "ok") { 56 + return data["subsonic-response"]; 57 + } 58 + throw new Error(data["subsonic-response"].error?.message || "API error"); 59 + } catch (error) { 60 + if (error.name === "AbortError") { 61 + throw new Error(`Request timeout after ${this.requestTimeout / 1000}s`); 62 + } 63 + throw new Error(`Request failed: ${error.message}`); 64 + } finally { 65 + clearTimeout(timeoutId); 66 + } 67 + } 68 + 69 + ping() { 70 + return this.request("ping.view"); 71 + } 72 + 73 + getIndexes() { 74 + return this.request("getIndexes.view"); 75 + } 76 + 77 + _validateAndRequest(id, method, params, context) { 78 + const validation = validateId(id, context); 79 + if (!validation.valid) throw new Error(validation.error); 80 + return this.request(method, { ...params, id }); 81 + } 82 + 83 + getArtist(id) { 84 + return this._validateAndRequest(id, "getArtist.view", {}, "Artist ID"); 85 + } 86 + 87 + getAlbum(id) { 88 + return this._validateAndRequest(id, "getAlbum.view", {}, "Album ID"); 89 + } 90 + 91 + getPlaylists() { 92 + return this.request("getPlaylists.view"); 93 + } 94 + 95 + getPlaylist(id) { 96 + return this._validateAndRequest(id, "getPlaylist.view", {}, "Playlist ID"); 97 + } 98 + 99 + getStreamUrl(id) { 100 + const validation = validateId(id, "Stream ID"); 101 + if (!validation.valid) throw new Error(validation.error); 102 + return this._buildAuthUrl("stream.view", { id }); 103 + } 104 + 105 + getCoverArtUrl(id, size = 64) { 106 + const validation = validateId(id, "Cover Art ID"); 107 + if (!validation.valid) throw new Error(validation.error); 108 + return this._buildAuthUrl("getCoverArt.view", { id, size }); 109 + } 110 + 111 + scrobble(id) { 112 + return this._validateAndRequest( 113 + id, 114 + "scrobble.view", 115 + { submission: true }, 116 + "Scrobble ID", 117 + ); 118 + } 119 + 120 + getStarred2() { 121 + return this.request("getStarred2.view"); 122 + } 123 + 124 + star(id) { 125 + return this._validateAndRequest(id, "star.view", {}, "Song ID"); 126 + } 127 + 128 + unstar(id) { 129 + return this._validateAndRequest(id, "unstar.view", {}, "Song ID"); 130 + } 131 + 132 + getLyricsBySongId(id) { 133 + return this._validateAndRequest( 134 + id, 135 + "getLyricsBySongId.view", 136 + {}, 137 + "Song ID", 138 + ); 139 + } 140 + 141 + getLyrics(artist, title) { 142 + if (!artist || !title) { 143 + throw new Error("Artist and title are required for getLyrics"); 144 + } 145 + return this.request("getLyrics.view", { artist, title }); 146 + } 147 + }
+81
src/js/auth.js
··· 1 + // credential manager with fallback handling 2 + const CredentialManager = { 3 + save: (server, username, password) => { 4 + localStorage.setItem( 5 + "tinysub_credentials", 6 + JSON.stringify({ server, username, password }), 7 + ); 8 + }, 9 + load: () => { 10 + try { 11 + const saved = localStorage.getItem("tinysub_credentials"); 12 + return saved 13 + ? JSON.parse(saved) 14 + : { server: "", username: "", password: "" }; 15 + } catch { 16 + // fallback to old format if atomic json fails 17 + return { 18 + server: localStorage.getItem("tinysub_server") || "", 19 + username: localStorage.getItem("tinysub_username") || "", 20 + password: localStorage.getItem("tinysub_password") || "", 21 + }; 22 + } 23 + }, 24 + }; 25 + 26 + // load library, playlists, and favorites after successful login 27 + async function initializeApp() { 28 + toggleAuthModal(false); 29 + await loadLibrary(); 30 + await loadPlaylists(); 31 + await loadFavorites(); 32 + } 33 + 34 + // handle login form submission with validation 35 + async function handleLogin() { 36 + if (handleLogin.isInProgress) return; 37 + 38 + const server = ui.serverInput.value; 39 + const username = ui.usernameInput.value; 40 + const password = ui.passwordInput.value; 41 + 42 + ui.authError.textContent = ""; 43 + 44 + const urlValidation = validateServerUrl(server); 45 + if (!urlValidation.valid) { 46 + ui.authError.textContent = urlValidation.error; 47 + return; 48 + } 49 + 50 + const credValidation = validateCredentials(username, password); 51 + if (!credValidation.valid) { 52 + ui.authError.textContent = credValidation.error; 53 + return; 54 + } 55 + 56 + handleLogin.isInProgress = true; 57 + const { 58 + value: { username: validUsername, password: validPassword }, 59 + } = credValidation; 60 + const validServerUrl = urlValidation.value; 61 + 62 + try { 63 + state.api = new SubsonicAPI(validServerUrl, validUsername, validPassword); 64 + // test connection and initialize app 65 + await state.api.ping(); 66 + CredentialManager.save(validServerUrl, validUsername, validPassword); 67 + await initializeApp(); 68 + } catch (error) { 69 + ui.authError.textContent = `${MESSAGES.CONNECTION_ERROR} ${error.message}`; 70 + state.api = null; 71 + } finally { 72 + handleLogin.isInProgress = false; 73 + } 74 + } 75 + handleLogin.isInProgress = false; 76 + 77 + // clear localStorage and reload to logout 78 + function handleLogout() { 79 + localStorage.clear(); 80 + location.reload(); 81 + }
+80
src/js/constants.js
··· 1 + // constants 2 + 3 + const CLASSES = { 4 + SELECTED: "selected", 5 + DRAGGING: "dragging", 6 + DRAG_OVER_ABOVE: "drag-over-above", 7 + DRAG_OVER_BELOW: "drag-over-below", 8 + CURRENTLY_PLAYING: "currently-playing", 9 + CONTEXT_MENU: "context-menu", 10 + CONTEXT_MENU_ITEM: "context-menu-item", 11 + TREE_TOGGLE: "tree-toggle", 12 + TREE_NAME: "tree-name", 13 + TREE_ITEM: "tree-item", 14 + NESTED: "nested", 15 + NESTED_SONGS: "nested-songs", 16 + QUEUE_ACTION: "queue-action", 17 + QUEUE_FAVORITE: "queue-favorite", 18 + QUEUE_PLAY_NEXT: "queue-play-next", 19 + QUEUE_MOVE_UP: "queue-move-up", 20 + QUEUE_MOVE_DOWN: "queue-move-down", 21 + QUEUE_REMOVE: "queue-remove", 22 + QUEUE_COVER: "queue-cover", 23 + FAVORITED: "favorited", 24 + ERROR: "error", 25 + DISABLED: "disabled", 26 + }; 27 + 28 + const DATA_ATTRS = { 29 + INDEX: "data-index", 30 + }; 31 + 32 + const DOM_IDS = { 33 + AUTH_MODAL: "auth-modal", 34 + LOGIN_FORM: "login-form", 35 + QUEUE_TABLE: "queue-table", 36 + AUTH_ERROR: "auth-error", 37 + SERVER_INPUT: "server", 38 + USERNAME_INPUT: "username", 39 + PASSWORD_INPUT: "password", 40 + LIBRARY_TREE: "artists-tree", 41 + PLAYLISTS_TREE: "playlists-tree", 42 + QUEUE_LIST: "queue-list", 43 + QUEUE_COUNT: "queue-count", 44 + COVER_ART: "cover-art", 45 + BACKGROUND_COVER: "background-cover", 46 + TRACK_TITLE: "track-title", 47 + TRACK_ARTIST: "track-artist", 48 + TRACK_LYRIC: "track-lyric", 49 + PLAYER: "player", 50 + PLAY_BTN: "play-btn", 51 + PREV_BTN: "prev-btn", 52 + NEXT_BTN: "next-btn", 53 + PROGRESS: "progress", 54 + TIME_DISPLAY: "time-display", 55 + LOGOUT_BTN: "logout-btn", 56 + SHUFFLE_BTN: "shuffle-btn", 57 + CLEAR_BTN: "clear-btn", 58 + SECTION_TOGGLE: "section-toggle", 59 + }; 60 + 61 + const MESSAGES = { 62 + NO_TRACK_PLAYING: "no track playing", 63 + PLAYBACK_FINISHED: "playback finished", 64 + NO_ARTISTS: "no artists", 65 + NO_PLAYLISTS: "no playlists", 66 + CONNECTION_ERROR: "connection failed:", 67 + }; 68 + 69 + const ICONS = { 70 + PLAY: "static/famfamfam-silk/control_play_blue.png", 71 + PAUSE: "static/famfamfam-silk/control_pause_blue.png", 72 + PREV: "static/famfamfam-silk/control_rewind_blue.png", 73 + NEXT: "static/famfamfam-silk/control_fastforward_blue.png", 74 + PLAY_NEXT: "static/famfamfam-silk/control_fastforward_blue.png", 75 + ADD: "static/famfamfam-silk/add.png", 76 + FAVORITE: "static/famfamfam-silk/heart.png", 77 + MOVE_UP: "static/famfamfam-silk/arrow_up.png", 78 + MOVE_DOWN: "static/famfamfam-silk/arrow_down.png", 79 + REMOVE: "static/famfamfam-silk/cross.png", 80 + };
+135
src/js/contextmenu.js
··· 1 + let contextMenuEl = null; 2 + let currentHideMenuHandler = null; 3 + 4 + // remove context menu 5 + function cleanupContextMenu() { 6 + if (contextMenuEl) { 7 + contextMenuEl.remove(); 8 + contextMenuEl = null; 9 + } 10 + if (currentHideMenuHandler) { 11 + ["click", "contextmenu"].forEach((event) => 12 + document.removeEventListener(event, currentHideMenuHandler, { 13 + capture: true, 14 + }), 15 + ); 16 + currentHideMenuHandler = null; 17 + } 18 + } 19 + 20 + // display context menu with given items at position 21 + function showContextMenu(x, y, items) { 22 + cleanupContextMenu(); 23 + 24 + contextMenuEl = createElement("div", { 25 + className: CLASSES.CONTEXT_MENU, 26 + attributes: { 27 + style: `left: ${Math.min(x, window.innerWidth - 200)}px; top: ${Math.min(y, window.innerHeight - 200)}px;`, 28 + }, 29 + }); 30 + 31 + Object.entries(items).forEach(([label, handler]) => { 32 + const item = createElement("button", { 33 + className: CLASSES.CONTEXT_MENU_ITEM, 34 + textContent: label, 35 + listeners: { click: handler }, 36 + }); 37 + contextMenuEl.appendChild(item); 38 + }); 39 + 40 + document.body.appendChild(contextMenuEl); 41 + 42 + currentHideMenuHandler = cleanupContextMenu; 43 + 44 + ["click", "contextmenu"].forEach((event) => 45 + document.addEventListener(event, currentHideMenuHandler, { capture: true }), 46 + ); 47 + } 48 + 49 + // setup right-click context menu for queue table 50 + function setupQueueContextMenu() { 51 + ui.queueList.addEventListener("contextmenu", (e) => { 52 + const row = getClosestRow(e.target); 53 + if (!row) return; 54 + 55 + e.preventDefault(); 56 + const idx = getRowIndex(row, DATA_ATTRS.INDEX); 57 + 58 + if (!selectionManager.isSelected(idx)) { 59 + selectionManager.select(idx); 60 + } 61 + 62 + const selectedIndices = Array.from(selectionManager.getSelected()); 63 + 64 + showContextMenu(e.clientX, e.clientY, { 65 + Play: () => { 66 + state.queueIndex = selectedIndices[0]; 67 + playTrack(state.queue[selectedIndices[0]]); 68 + updateQueue(false); 69 + }, 70 + "Play Next": () => { 71 + const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 72 + moveQueueItems( 73 + state.queue, 74 + selectedIndices, 75 + insertPos, 76 + getQueueCallbacks(), 77 + ); 78 + }, 79 + Favorite: async () => { 80 + await Promise.all( 81 + selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 82 + ); 83 + updateQueueDisplay(); 84 + }, 85 + Unfavorite: async () => { 86 + await Promise.all( 87 + selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)), 88 + ); 89 + updateQueueDisplay(); 90 + }, 91 + "Move Up": () => { 92 + const firstIdx = Math.min(...selectedIndices); 93 + if (firstIdx > 0) { 94 + moveQueueItems( 95 + state.queue, 96 + selectedIndices, 97 + firstIdx - 1, 98 + getQueueCallbacks(), 99 + ); 100 + } 101 + }, 102 + "Move Down": () => { 103 + const lastIdx = Math.max(...selectedIndices); 104 + if (lastIdx < state.queue.length - 1) { 105 + moveQueueItems( 106 + state.queue, 107 + selectedIndices, 108 + lastIdx + 2, 109 + getQueueCallbacks(), 110 + ); 111 + } 112 + }, 113 + Delete: removeSelectedRows, 114 + }); 115 + }); 116 + 117 + document.addEventListener( 118 + "contextmenu", 119 + (e) => { 120 + const isQueueTable = e.target.closest(`#${DOM_IDS.QUEUE_TABLE}`); 121 + 122 + if (contextMenuEl && isQueueTable) { 123 + contextMenuEl.remove(); 124 + contextMenuEl = null; 125 + return; 126 + } 127 + 128 + if (!isQueueTable || contextMenuEl) { 129 + e.preventDefault(); 130 + e.stopImmediatePropagation(); 131 + } 132 + }, 133 + true, 134 + ); 135 + }
+65
src/js/draggable.js
··· 1 + // setup drag and drop for queue reordering 2 + function setupDragAndDrop() { 3 + const clearDragOver = () => 4 + clearRowClasses(ui.queueList, "tr:not(.virtual-spacer)", [ 5 + CLASSES.DRAG_OVER_ABOVE, 6 + CLASSES.DRAG_OVER_BELOW, 7 + CLASSES.DRAGGING, 8 + ]); 9 + const isDraggable = (row) => 10 + row && 11 + !row.classList.contains(CLASSES.DRAGGING) && 12 + !row.classList.contains("virtual-spacer"); 13 + const isDropBelowCenter = (e, row) => 14 + e.clientY - row.getBoundingClientRect().top > row.offsetHeight / 2; 15 + 16 + ui.queueList.addEventListener("dragstart", (e) => { 17 + const row = getClosestRow(e.target); 18 + if (!row || selectionManager.isSelected(getRowIndex(row, DATA_ATTRS.INDEX))) 19 + return; 20 + 21 + clearSelection(); 22 + selectRow(getRowIndex(row, DATA_ATTRS.INDEX)); 23 + updateSelectionUI(); 24 + updateRowClass(ui.queueList, getSelectedIndices(), CLASSES.DRAGGING, true); 25 + e.dataTransfer.effectAllowed = "move"; 26 + e.dataTransfer.setDragImage(row, 0, 0); 27 + }); 28 + 29 + // handle drag over - show drop indicator 30 + ui.queueList.addEventListener("dragover", (e) => { 31 + e.preventDefault(); 32 + e.dataTransfer.dropEffect = "move"; 33 + const row = getClosestRow(e.target); 34 + if (!isDraggable(row)) return; 35 + 36 + clearRowClasses(ui.queueList, "tr:not(.virtual-spacer)", [ 37 + CLASSES.DRAG_OVER_ABOVE, 38 + CLASSES.DRAG_OVER_BELOW, 39 + ]); 40 + row.classList.add( 41 + isDropBelowCenter(e, row) 42 + ? CLASSES.DRAG_OVER_BELOW 43 + : CLASSES.DRAG_OVER_ABOVE, 44 + ); 45 + }); 46 + 47 + // handle drop and complete the move 48 + ui.queueList.addEventListener("drop", (e) => { 49 + e.preventDefault(); 50 + const row = getClosestRow(e.target); 51 + if (!isDraggable(row)) return; 52 + 53 + clearDragOver(); 54 + const draggedIdx = getRowIndex(row, DATA_ATTRS.INDEX); 55 + moveQueueItems( 56 + state.queue, 57 + getSelectedIndices(), 58 + isDropBelowCenter(e, row) ? draggedIdx + 1 : draggedIdx, 59 + getQueueCallbacks(), 60 + ); 61 + }); 62 + 63 + // cleanup on drag end 64 + ui.queueList.addEventListener("dragend", clearDragOver); 65 + }
+213
src/js/events.js
··· 1 + let selectionManager; 2 + 3 + // queue operation callbacks for use in move/modification handlers 4 + const getQueueCallbacks = () => ({ 5 + onSelectionChange: (newIndices) => selectionManager.setSelection(newIndices), 6 + onQueueChange: () => updateQueueDisplay(), 7 + }); 8 + 9 + // map button classes to handler functions 10 + const QUEUE_BUTTON_HANDLERS = { 11 + // handler for action buttons (play, move, remove, favorite) on queue rows 12 + [CLASSES.QUEUE_ACTION]: (idx) => { 13 + state.queueIndex = idx; 14 + playTrack(state.queue[state.queueIndex]); 15 + updateQueue(false); 16 + }, 17 + [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { 18 + const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 19 + moveQueueItems(state.queue, [idx], insertPos, getQueueCallbacks()); 20 + }, 21 + [CLASSES.QUEUE_MOVE_UP]: (idx) => { 22 + const newIdx = idx - 1; 23 + if (newIdx >= 0 && newIdx < state.queue.length) { 24 + moveQueueItems(state.queue, [idx], newIdx, getQueueCallbacks()); 25 + } 26 + }, 27 + [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { 28 + const newIdx = idx + 1; 29 + if (newIdx >= 0 && newIdx < state.queue.length) { 30 + moveQueueItems(state.queue, [idx], newIdx + 1, getQueueCallbacks()); 31 + } 32 + }, 33 + [CLASSES.QUEUE_REMOVE]: (idx) => { 34 + state.queue.splice(idx, 1); 35 + updateQueue(); 36 + }, 37 + [CLASSES.QUEUE_FAVORITE]: async (idx) => { 38 + const song = state.queue[idx]; 39 + if (song) { 40 + await setFavoriteSong(song); 41 + updateQueueDisplay(); 42 + } 43 + }, 44 + }; 45 + 46 + // setup media control handlers for hardware buttons and lock screen 47 + function setupMediaSessionHandlers() { 48 + if (!navigator.mediaSession) return; 49 + 50 + const handlers = { 51 + play: () => ui.player.play(), 52 + pause: () => ui.player.pause(), 53 + previoustrack: previousTrack, 54 + nexttrack: nextTrack, 55 + seekto: (e) => (ui.player.currentTime = e.seekTime), 56 + }; 57 + 58 + Object.entries(handlers).forEach(([action, handler]) => 59 + navigator.mediaSession.setActionHandler(action, handler), 60 + ); 61 + } 62 + 63 + document.addEventListener("DOMContentLoaded", async () => { 64 + // initialize selection manager for queue table 65 + selectionManager = new SelectionManager(ui.queueList, { 66 + rowSelector: "tr", 67 + indexAttribute: DATA_ATTRS.INDEX, 68 + selectedClass: CLASSES.SELECTED, 69 + }); 70 + 71 + // setup settings modal 72 + setupSettings(); 73 + 74 + ui.loginForm.addEventListener("submit", (e) => { 75 + e.preventDefault(); 76 + handleLogin(); 77 + }); 78 + ui.logoutBtn.addEventListener("click", handleLogout); 79 + 80 + // setup audio element event listeners 81 + ["play", "pause"].forEach((event) => 82 + ui.player.addEventListener(event, updatePlayIcon), 83 + ); 84 + ui.player.addEventListener("ended", handleTrackEnd); 85 + ui.player.addEventListener("loadedmetadata", () => { 86 + ui.progress.max = ui.player.duration; 87 + }); 88 + ui.player.addEventListener("timeupdate", () => { 89 + ui.progress.value = ui.player.currentTime; 90 + const current = formatDuration(ui.player.currentTime); 91 + const total = formatDuration(ui.player.duration || 0); 92 + ui.timeDisplay.textContent = `${current} / ${total}`; 93 + updateMediaSessionPosition(); 94 + updateLyricDisplay(ui.player.currentTime); 95 + }); 96 + 97 + // helper to check if queue has a valid current track 98 + const hasValidTrack = () => 99 + isValidQueueIndex(state.queueIndex, state.queue.length); 100 + 101 + ui.playBtn.addEventListener("click", () => { 102 + if (hasValidTrack()) { 103 + if (ui.player.src) { 104 + ui.player.paused ? ui.player.play() : ui.player.pause(); 105 + } else { 106 + playTrack(state.queue[state.queueIndex]); 107 + } 108 + } else if (state.queue.length > 0) { 109 + state.queueIndex = 0; 110 + playTrack(state.queue[0]); 111 + updateQueue(false); 112 + } 113 + }); 114 + ui.prevBtn.addEventListener("click", previousTrack); 115 + ui.nextBtn.addEventListener("click", nextTrack); 116 + 117 + const handleProgressSeek = (e) => { 118 + ui.player.currentTime = parseFloat(e.target.value); 119 + }; 120 + ["input", "change"].forEach((event) => 121 + ui.progress.addEventListener(event, handleProgressSeek), 122 + ); 123 + 124 + // shuffle button, preserve current track position during shuffle 125 + ui.shuffleBtn.addEventListener("click", () => { 126 + const currentTrack = hasValidTrack() ? state.queue[state.queueIndex] : null; 127 + shuffleQueue(); 128 + if (currentTrack) { 129 + state.queueIndex = state.queue.indexOf(currentTrack); 130 + updateQueue(false); 131 + } 132 + }); 133 + ui.clearBtn.addEventListener("click", () => { 134 + clearQueue(); 135 + resetPlayerUI(); 136 + }); 137 + // queue table interactions 138 + ui.queueList.addEventListener("click", (e) => { 139 + const btn = e.target.closest("button"); 140 + if (!btn) { 141 + // handle row selection on click 142 + const row = getClosestRow(e.target); 143 + if (row) { 144 + const idx = getRowIndex(row, DATA_ATTRS.INDEX); 145 + const isMulti = e.ctrlKey || e.metaKey; 146 + const isShift = e.shiftKey; 147 + selectRow(idx, isMulti, isShift); 148 + } 149 + return; 150 + } 151 + 152 + const row = getClosestRow(btn); 153 + const idx = getRowIndex(row, DATA_ATTRS.INDEX); 154 + 155 + for (const [className, handler] of Object.entries(QUEUE_BUTTON_HANDLERS)) { 156 + if (btn.classList.contains(className)) { 157 + handler(idx); 158 + break; 159 + } 160 + } 161 + }); 162 + 163 + // double click to play 164 + ui.queueList.addEventListener("dblclick", (e) => { 165 + const row = getClosestRow(e.target); 166 + if (row) { 167 + const idx = getRowIndex(row, DATA_ATTRS.INDEX); 168 + state.queueIndex = idx; 169 + saveQueue(); 170 + playTrack(state.queue[idx]); 171 + } 172 + }); 173 + 174 + // deselect when clicking outside queue table 175 + document.addEventListener("click", (e) => { 176 + if ( 177 + !e.target.closest(`#${DOM_IDS.QUEUE_TABLE}`) && 178 + !e.target.closest(".context-menu") 179 + ) { 180 + clearSelection(); 181 + } 182 + }); 183 + 184 + ui.sectionToggles.forEach((toggle) => 185 + toggle.addEventListener("click", handleSectionToggle), 186 + ); 187 + 188 + // register event handlers and restore previous state 189 + setupDragAndDrop(); 190 + setupQueueContextMenu(); 191 + // initialize media session once 192 + setupMediaSessionHandlers(); 193 + 194 + // restore auto-login if credentials saved 195 + const credentials = CredentialManager.load(); 196 + if (credentials.server && credentials.username && credentials.password) { 197 + state.api = new SubsonicAPI( 198 + credentials.server, 199 + credentials.username, 200 + credentials.password, 201 + ); 202 + loadQueue(); 203 + updateQueueDisplay(); 204 + await initializeApp(); 205 + // restore song display after init 206 + if (state.queueIndex >= 0 && state.queueIndex < state.queue.length) { 207 + playTrack(state.queue[state.queueIndex]); 208 + ui.player.pause(); 209 + } 210 + } else { 211 + toggleAuthModal(true); 212 + } 213 + });
+222
src/js/library.js
··· 1 + const playNextByType = { 2 + artist: (id) => 3 + addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist, true), 4 + album: (id) => 5 + addToQueue(() => state.api.getAlbum(id), SONG_EXTRACTORS.album, true), 6 + playlist: (id) => 7 + addToQueue(() => state.api.getPlaylist(id), SONG_EXTRACTORS.playlist, true), 8 + song: (song) => 9 + addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song, true), 10 + }; 11 + 12 + async function loadData(config) { 13 + const { fetcher, transformer, stateKey, renderFn } = config; 14 + const data = await fetcher(); 15 + state[stateKey] = transformer(data); 16 + renderFn(); 17 + } 18 + 19 + async function loadLibrary() { 20 + return loadData({ 21 + fetcher: () => state.api.getIndexes(), 22 + transformer: (data) => 23 + data.indexes?.index?.flatMap((idx) => idx.artist || []) || [], 24 + stateKey: "library", 25 + renderFn: renderLibraryTree, 26 + }); 27 + } 28 + 29 + async function loadPlaylists() { 30 + return loadData({ 31 + fetcher: () => state.api.getPlaylists(), 32 + transformer: (data) => data.playlist || data.playlists?.playlist || [], 33 + stateKey: "playlists", 34 + renderFn: renderPlaylistsTree, 35 + }); 36 + } 37 + 38 + async function loadFavorites() { 39 + const data = await state.api.getStarred2(); 40 + const songs = data.starred2?.song || data.song || []; 41 + state.favorites.clear(); 42 + songs.forEach((song) => state.favorites.add(song.id)); 43 + updateQueueDisplay(); 44 + } 45 + 46 + function renderTree(config) { 47 + const { 48 + container, 49 + expandedKey, 50 + items, 51 + emptyMessage, 52 + itemMapper, 53 + onToggle, 54 + onAction, 55 + onPlayNext, 56 + artType, 57 + } = config; 58 + 59 + container.innerHTML = ""; 60 + if (!state.expanded[expandedKey]) return; 61 + if (!items.length) { 62 + container.innerHTML = `<div class="item">${emptyMessage}</div>`; 63 + return; 64 + } 65 + 66 + const ul = createElement("ul"); 67 + items.forEach((item) => { 68 + const mapped = itemMapper(item); 69 + const li = createTreeItem( 70 + mapped.label, 71 + mapped.cover || null, 72 + onToggle ? (li) => onToggle(item, li) : null, 73 + () => onAction(item), 74 + onPlayNext ? () => onPlayNext(item) : null, 75 + artType, 76 + item.id, 77 + ); 78 + ul.appendChild(li); 79 + }); 80 + container.appendChild(ul); 81 + } 82 + 83 + async function loadAndRenderItems(config) { 84 + const { 85 + fetcher, 86 + itemExtractor, 87 + parentLi, 88 + ulClassName, 89 + itemMapper, 90 + onToggle, 91 + onAction, 92 + onPlayNext, 93 + onExpanded, 94 + artType, 95 + } = config; 96 + try { 97 + const data = await fetcher(); 98 + const items = itemExtractor(data); 99 + if (!items.length) return; 100 + 101 + const ul = createElement("ul", { className: ulClassName }); 102 + items.forEach((item) => { 103 + const mapped = itemMapper(item); 104 + const li = createTreeItem( 105 + mapped.label, 106 + mapped.cover || null, 107 + onToggle ? (li) => onToggle(item, li) : null, 108 + () => onAction(item), 109 + onPlayNext ? () => onPlayNext(item) : null, 110 + artType, 111 + item.id, 112 + ); 113 + ul.appendChild(li); 114 + if (onExpanded && mapped.isExpanded) onExpanded(item, li); 115 + }); 116 + parentLi.appendChild(ul); 117 + } catch (error) { 118 + // silently handle load errors 119 + } 120 + } 121 + 122 + async function loadAndRenderAlbums(artistId, parentLi) { 123 + return loadAndRenderItems({ 124 + fetcher: () => state.api.getArtist(artistId), 125 + itemExtractor: (data) => data.artist?.album || [], 126 + parentLi, 127 + ulClassName: CLASSES.NESTED, 128 + artType: "artAlbum", 129 + itemMapper: (album) => ({ 130 + label: album.name, 131 + cover: shouldShowArt("artAlbum", album.coverArt), 132 + isExpanded: state.expanded.items.albums[album.id], 133 + }), 134 + onToggle: (album, li) => { 135 + state.expanded.items.albums[album.id] = 136 + !state.expanded.items.albums[album.id]; 137 + // clear any existing songs before toggling 138 + li.querySelector(`ul.${CLASSES.NESTED_SONGS}`)?.remove(); 139 + if (state.expanded.items.albums[album.id]) { 140 + loadAndRenderSongs(album.id, li); 141 + } 142 + }, 143 + onExpanded: (album, li) => loadAndRenderSongs(album.id, li), 144 + onAction: (album) => addAlbumToQueue(album.id), 145 + onPlayNext: (album) => playNextByType.album(album.id), 146 + }); 147 + } 148 + 149 + async function loadAndRenderSongs(albumId, parentLi) { 150 + return loadAndRenderItems({ 151 + fetcher: () => state.api.getAlbum(albumId), 152 + itemExtractor: (data) => data.album?.song || [], 153 + parentLi, 154 + ulClassName: CLASSES.NESTED_SONGS, 155 + artType: "artSong", 156 + itemMapper: (song) => ({ 157 + label: song.title, 158 + }), 159 + onAction: (song) => addSongToQueue(song), 160 + onPlayNext: (song) => playNextByType.song(song), 161 + }); 162 + } 163 + 164 + function renderLibraryTree() { 165 + renderTree({ 166 + container: ui.artistsTree, 167 + expandedKey: "artists", 168 + items: state.library, 169 + emptyMessage: MESSAGES.NO_ARTISTS, 170 + artType: "artArtist", 171 + itemMapper: (artist) => ({ 172 + label: artist.title || artist.name, 173 + cover: shouldShowArt("artArtist", artist.coverArt), 174 + }), 175 + onToggle: (artist, li) => { 176 + state.expanded.items.artists[artist.id] = 177 + !state.expanded.items.artists[artist.id]; 178 + // clear any existing albums before toggling 179 + li.querySelector(`ul.${CLASSES.NESTED}`)?.remove(); 180 + if (state.expanded.items.artists[artist.id]) { 181 + loadAndRenderAlbums(artist.id, li); 182 + } 183 + }, 184 + onAction: (artist) => addArtistToQueue(artist.id), 185 + onPlayNext: (artist) => playNextByType.artist(artist.id), 186 + }); 187 + 188 + // restore expanded artists after re-render 189 + restoreExpandedItems( 190 + state.library, 191 + state.expanded.items.artists, 192 + ui.artistsTree, 193 + ); 194 + } 195 + 196 + function restoreExpandedItems(items, expandedMap, container) { 197 + items.forEach((item) => { 198 + if (expandedMap[item.id]) { 199 + const li = container.querySelector(`li[data-item-id="${item.id}"]`); 200 + if (li && !li.querySelector(`ul.${CLASSES.NESTED}`)) { 201 + loadAndRenderAlbums(item.id, li); 202 + } 203 + } 204 + }); 205 + } 206 + 207 + function renderPlaylistsTree() { 208 + renderTree({ 209 + container: ui.playlistsTree, 210 + expandedKey: "playlists", 211 + items: state.playlists, 212 + emptyMessage: MESSAGES.NO_PLAYLISTS, 213 + artType: "artArtist", 214 + itemMapper: (playlist) => ({ 215 + label: playlist.name, 216 + cover: null, 217 + }), 218 + onToggle: null, 219 + onAction: (playlist) => addPlaylistToQueue(playlist.id), 220 + onPlayNext: (playlist) => playNextByType.playlist(playlist.id), 221 + }); 222 + }
+93
src/js/lyrics.js
··· 1 + let currentLyrics = []; 2 + 3 + function parseLineArray(lines, timeField = "time", forceZeroTime = false) { 4 + if (!Array.isArray(lines)) return []; 5 + return lines 6 + .map((line) => { 7 + const text = (line.value || "").trim(); 8 + if (text) { 9 + const timeMs = forceZeroTime ? 0 : parseInt(line[timeField]) || 0; 10 + return { time: timeMs / 1000, text }; 11 + } 12 + }) 13 + .filter(Boolean) 14 + .sort((a, b) => a.time - b.time); 15 + } 16 + 17 + function parseLyrics(input) { 18 + if (!input) return []; 19 + 20 + const lines = 21 + typeof input === "string" ? input.split("\n") : input.line ? [input] : []; 22 + const parsed = []; 23 + 24 + for (const line of lines) { 25 + const lrcMatch = line.match?.(/\[(\d+):(\d+)\.(\d+)\](.*)/); 26 + if (lrcMatch) { 27 + const [_, m, s, ms, text] = lrcMatch; 28 + const time = parseInt(m) * 60 + parseInt(s) + parseInt(ms) / 1000; 29 + if (text.trim()) parsed.push({ time, text: text.trim() }); 30 + } 31 + } 32 + 33 + if (parsed.length === 0 && typeof input === "string" && input.trim()) { 34 + return [{ time: 0, text: input.trim() }]; 35 + } 36 + 37 + return parsed.sort((a, b) => a.time - b.time); 38 + } 39 + 40 + function parseStructured(array) { 41 + if (!Array.isArray(array)) return []; 42 + for (const entry of array) { 43 + if (entry.line?.length) { 44 + const parsed = parseLineArray(entry.line, "start", !entry.synced); 45 + if (parsed.length > 0) return parsed; 46 + } 47 + } 48 + return []; 49 + } 50 + 51 + function getCurrentLyric(currentTime) { 52 + if (!currentLyrics.length) return null; 53 + for (let i = currentLyrics.length - 1; i >= 0; i--) { 54 + if (currentLyrics[i].time <= currentTime) return currentLyrics[i]; 55 + } 56 + return null; 57 + } 58 + 59 + // fetch and display lyrics for current song 60 + async function loadLyricsForSong(song) { 61 + if (!song || !state.api) return; 62 + 63 + try { 64 + if (song.id) { 65 + const response = await state.api.getLyricsBySongId(song.id); 66 + const structuredArray = response.lyricsList?.structuredLyrics; 67 + if (structuredArray) { 68 + currentLyrics = parseStructured(structuredArray); 69 + updateLyricDisplay(ui.player.currentTime); 70 + return; 71 + } 72 + } 73 + 74 + if (song.artist && song.title) { 75 + const response = await state.api.getLyrics(song.artist, song.title); 76 + currentLyrics = parseLyrics(response.lyrics.value || response.lyrics); 77 + updateLyricDisplay(ui.player.currentTime); 78 + } 79 + } catch { 80 + currentLyrics = []; 81 + ui.trackLyric.textContent = ""; 82 + } 83 + } 84 + 85 + function updateLyricDisplay(currentTime) { 86 + const lyric = getCurrentLyric(currentTime); 87 + ui.trackLyric.textContent = lyric ? `♪ ${lyric.text}` : ""; 88 + } 89 + 90 + function clearLyrics() { 91 + currentLyrics = []; 92 + ui.trackLyric.textContent = ""; 93 + }
+145
src/js/player.js
··· 1 + // load and play a song 2 + function playTrack(song) { 3 + if (!song) { 4 + resetPlayerUI(); 5 + return; 6 + } 7 + 8 + ui.trackTitle.textContent = song.title || "Unknown"; 9 + ui.trackArtist.textContent = song.artist || "Unknown Artist"; 10 + setCoverArt(song); 11 + ui.player.src = state.api.getStreamUrl(song.id); 12 + ui.player.play(); 13 + loadLyricsForSong(song); 14 + highlightCurrentTrack(); 15 + updateMediaSession(song); 16 + } 17 + 18 + // set cover art in player UI and background 19 + function setCoverArt(song) { 20 + const backgroundCover = document.getElementById(DOM_IDS.BACKGROUND_COVER); 21 + if (song?.coverArt) { 22 + const coverUrl = state.api.getCoverArtUrl(song.coverArt, 512); 23 + ui.coverArt.src = coverUrl; 24 + if (backgroundCover) 25 + backgroundCover.style.backgroundImage = `url('${coverUrl}')`; 26 + } else { 27 + ui.coverArt.src = ""; 28 + if (backgroundCover) backgroundCover.style.backgroundImage = ""; 29 + } 30 + } 31 + 32 + // move to next or previous track in queue 33 + function navigateTrack(offset) { 34 + const newIndex = state.queueIndex + offset; 35 + if (newIndex >= 0 && newIndex < state.queue.length) { 36 + state.queueIndex = newIndex; 37 + saveQueue(); 38 + playTrack(state.queue[state.queueIndex]); 39 + } 40 + } 41 + 42 + const previousTrack = () => navigateTrack(-1); 43 + const nextTrack = () => navigateTrack(1); 44 + 45 + // toggle favorite status of a song 46 + const setFavoriteSong = async (song, force) => { 47 + if (!song || !state.api) return; 48 + const isFavorited = state.favorites.has(song.id); 49 + const shouldAdd = force === undefined ? !isFavorited : force; 50 + if (shouldAdd === isFavorited) return; 51 + if (shouldAdd) { 52 + await state.api.star(song.id); 53 + state.favorites.add(song.id); 54 + } else { 55 + await state.api.unstar(song.id); 56 + state.favorites.delete(song.id); 57 + } 58 + }; 59 + 60 + // highlight currently playing song in queue 61 + function highlightCurrentTrack() { 62 + clearRowClasses(ui.queueList, "tr", ["currently-playing"]); 63 + if (state.queueIndex >= 0) { 64 + const row = ui.queueList.querySelector( 65 + `tr[data-index="${state.queueIndex}"]`, 66 + ); 67 + if (row) row.classList.add("currently-playing"); 68 + } 69 + } 70 + 71 + // move to next track when current song ends 72 + function handleTrackEnd() { 73 + if ( 74 + isValidQueueIndex(state.queueIndex, state.queue.length) && 75 + state.settings.scrobbling 76 + ) { 77 + state.api?.scrobble(state.queue[state.queueIndex].id).catch(() => {}); 78 + } 79 + state.queueIndex++; 80 + if (state.queueIndex < state.queue.length) { 81 + saveQueue(); 82 + playTrack(state.queue[state.queueIndex]); 83 + } else { 84 + ui.player.src = ""; 85 + ui.trackTitle.textContent = MESSAGES.PLAYBACK_FINISHED; 86 + } 87 + } 88 + 89 + // reset player when stopping or clearing queue 90 + function resetPlayerUI() { 91 + ui.player.pause(); 92 + ui.player.src = ""; 93 + ui.trackTitle.textContent = MESSAGES.NO_TRACK_PLAYING; 94 + ui.trackArtist.textContent = ""; 95 + clearLyrics(); 96 + ui.coverArt.src = "static/subsonic.png"; 97 + ui.progress.value = 0; 98 + ui.timeDisplay.textContent = "0:00 / 0:00"; 99 + updatePlayIcon(); 100 + withMediaSession((ms) => (ms.metadata = null)); 101 + } 102 + 103 + // safely call mediaSession methods if available 104 + const withMediaSession = (callback) => { 105 + if (navigator.mediaSession) callback(navigator.mediaSession); 106 + }; 107 + 108 + // update media session metadata with current song 109 + const updateMediaSession = (song) => { 110 + if (!song) return; 111 + withMediaSession((ms) => { 112 + ms.metadata = new MediaMetadata({ 113 + title: song.title || "Unknown", 114 + artist: song.artist || "Unknown Artist", 115 + album: song.album || "", 116 + artwork: song.coverArt 117 + ? [ 118 + { 119 + src: state.api.getCoverArtUrl(song.coverArt, 256), 120 + sizes: "512x512", 121 + type: "image/jpeg", 122 + }, 123 + ] 124 + : [], 125 + }); 126 + updateMediaSessionPosition(); 127 + }); 128 + }; 129 + 130 + let lastMediaSessionUpdateTime = 0; 131 + // throttled update of playback position for media session 132 + const updateMediaSessionPosition = () => { 133 + const now = Date.now(); 134 + if (now - lastMediaSessionUpdateTime < 200) { 135 + return; 136 + } 137 + lastMediaSessionUpdateTime = now; 138 + withMediaSession((ms) => { 139 + ms.setPositionState({ 140 + duration: ui.player.duration || 0, 141 + playbackRate: 1, 142 + position: ui.player.currentTime || 0, 143 + }); 144 + }); 145 + };
+389
src/js/queue.js
··· 1 + // some of this was taken from various sources on stackoverflow and some of it was also ai-assisted, it's likely rough around the edges in places and could use improvements 2 + // too complex for my brain 🐈 3 + 4 + // persist queue and current index to localStorage 5 + function saveQueue() { 6 + try { 7 + const serialized = state.queue.map((song) => ({ 8 + id: song.id, 9 + title: song.title, 10 + artist: song.artist, 11 + album: song.album, 12 + coverArt: song.coverArt, 13 + duration: song.duration, 14 + })); 15 + localStorage.setItem("tinysub_queue", JSON.stringify(serialized)); 16 + localStorage.setItem( 17 + "tinysub_queue_index", 18 + JSON.stringify(state.queueIndex), 19 + ); 20 + } catch (e) { 21 + // quota exceeded, just save current track index 22 + if (e.name === "QuotaExceededError") { 23 + try { 24 + localStorage.setItem( 25 + "tinysub_queue_index", 26 + JSON.stringify(state.queueIndex), 27 + ); 28 + } catch { 29 + // even index won't fit, give up silently 30 + } 31 + } 32 + } 33 + } 34 + 35 + // update queue display, highlight current track, and save state 36 + function updateQueue(updateDisplay = true) { 37 + if (updateDisplay) updateQueueDisplay(); 38 + saveQueue(); 39 + } 40 + 41 + // restore queue from localStorage 42 + function loadQueue() { 43 + const saved = localStorage.getItem("tinysub_queue"); 44 + if (!saved) return false; 45 + 46 + try { 47 + state.queue = JSON.parse(saved); 48 + const savedIndex = localStorage.getItem("tinysub_queue_index"); 49 + if (savedIndex !== null) { 50 + const index = JSON.parse(savedIndex); 51 + if (isValidQueueIndex(index, state.queue.length)) { 52 + state.queueIndex = index; 53 + } 54 + } 55 + return true; 56 + } catch { 57 + return false; 58 + } 59 + } 60 + 61 + const SONG_EXTRACTORS = { 62 + artist: async (data) => { 63 + const albums = data.artist?.album || []; 64 + const songArrays = await Promise.all( 65 + albums.map((album) => 66 + state.api 67 + .getAlbum(album.id) 68 + .then((result) => result.album?.song || []) 69 + .catch(() => []), 70 + ), 71 + ); 72 + return songArrays.flat(); 73 + }, 74 + album: (data) => data.album?.song || [], 75 + playlist: (data) => data.entry || data.playlist?.entry || [], 76 + song: (s) => [s], 77 + }; 78 + 79 + // add songs to queue (with optional insertNext mode) 80 + async function addToQueue(fetcher, songExtractor, insertNext = false) { 81 + const songs = await songExtractor(await fetcher()); 82 + if (insertNext) { 83 + const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 84 + state.queue.splice(insertPos, 0, ...songs); 85 + } else { 86 + state.queue.push(...songs); 87 + } 88 + updateQueue(); 89 + } 90 + 91 + const addArtistToQueue = (id) => 92 + addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist); 93 + const addAlbumToQueue = (id) => 94 + addToQueue(() => state.api.getAlbum(id), SONG_EXTRACTORS.album); 95 + const addPlaylistToQueue = (id) => 96 + addToQueue(() => state.api.getPlaylist(id), SONG_EXTRACTORS.playlist); 97 + const addSongToQueue = (song) => 98 + addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song); 99 + 100 + // virtual scrolling state with dynamic row height measurement 101 + const VIRTUAL_SCROLL = { 102 + rowHeight: null, 103 + buffer: 4, 104 + container: null, 105 + visibleStart: 0, 106 + visibleEnd: 0, 107 + initialized: false, 108 + measured: false, 109 + rowCache: new Map(), 110 + scrollScheduled: false, 111 + }; 112 + 113 + // setup scroll and resize listeners 114 + function initVirtualScroll() { 115 + if (VIRTUAL_SCROLL.initialized) return; 116 + VIRTUAL_SCROLL.initialized = true; 117 + 118 + VIRTUAL_SCROLL.container = document.querySelector("main"); 119 + if (!VIRTUAL_SCROLL.container) return; 120 + 121 + VIRTUAL_SCROLL.container.addEventListener("scroll", handleVirtualScroll, { 122 + passive: true, 123 + }); 124 + window.addEventListener("resize", () => { 125 + VIRTUAL_SCROLL.rowHeight = null; 126 + VIRTUAL_SCROLL.measured = false; 127 + VIRTUAL_SCROLL.rowCache.clear(); 128 + handleVirtualScroll(true); 129 + }); 130 + } 131 + 132 + // calculate which rows are visible given current scroll position 133 + function calculateVisibleRange(scrollTop, viewportHeight) { 134 + if (!VIRTUAL_SCROLL.rowHeight) { 135 + return { start: 0, end: state.queue.length }; 136 + } 137 + return { 138 + start: Math.max( 139 + 0, 140 + Math.floor(scrollTop / VIRTUAL_SCROLL.rowHeight) - VIRTUAL_SCROLL.buffer, 141 + ), 142 + end: Math.min( 143 + state.queue.length, 144 + Math.ceil((scrollTop + viewportHeight) / VIRTUAL_SCROLL.rowHeight) + 145 + VIRTUAL_SCROLL.buffer, 146 + ), 147 + }; 148 + } 149 + 150 + // update visible rows on scroll (throttled with requestAnimationFrame) 151 + function handleVirtualScroll(forceRender = false) { 152 + if (!VIRTUAL_SCROLL.container) return; 153 + 154 + if (VIRTUAL_SCROLL.scrollScheduled && !forceRender) return; 155 + VIRTUAL_SCROLL.scrollScheduled = true; 156 + 157 + requestAnimationFrame(() => { 158 + const range = calculateVisibleRange( 159 + VIRTUAL_SCROLL.container.scrollTop, 160 + VIRTUAL_SCROLL.container.clientHeight, 161 + ); 162 + 163 + if ( 164 + forceRender || 165 + range.start !== VIRTUAL_SCROLL.visibleStart || 166 + range.end !== VIRTUAL_SCROLL.visibleEnd 167 + ) { 168 + VIRTUAL_SCROLL.visibleStart = range.start; 169 + VIRTUAL_SCROLL.visibleEnd = range.end; 170 + renderVirtualRows(forceRender); 171 + highlightCurrentTrack(); 172 + } 173 + VIRTUAL_SCROLL.scrollScheduled = false; 174 + }); 175 + } 176 + 177 + // render visible rows and spacers, measure actual row height 178 + function renderVirtualRows(forceRender = true) { 179 + const { visibleStart, visibleEnd } = VIRTUAL_SCROLL; 180 + 181 + for (const [idx, row] of VIRTUAL_SCROLL.rowCache.entries()) { 182 + if (idx < visibleStart || idx >= visibleEnd) { 183 + row.remove(); 184 + VIRTUAL_SCROLL.rowCache.delete(idx); 185 + } 186 + } 187 + 188 + const newContent = []; 189 + 190 + // create spacer row (invisible, used for scroll height) 191 + const createSpacer = (rowCount) => { 192 + const spacer = document.createElement("tr"); 193 + spacer.style.height = `${rowCount * VIRTUAL_SCROLL.rowHeight}px`; 194 + spacer.classList.add("virtual-spacer"); 195 + return spacer; 196 + }; 197 + 198 + if (visibleStart > 0) newContent.push(createSpacer(visibleStart)); 199 + 200 + for (let idx = visibleStart; idx < visibleEnd; idx++) { 201 + let row = VIRTUAL_SCROLL.rowCache.get(idx); 202 + if (!row) { 203 + row = createQueueRow(state.queue[idx], idx); 204 + VIRTUAL_SCROLL.rowCache.set(idx, row); 205 + } 206 + newContent.push(row); 207 + } 208 + 209 + const remainingRows = state.queue.length - visibleEnd; 210 + if (remainingRows > 0) newContent.push(createSpacer(remainingRows)); 211 + 212 + ui.queueList.replaceChildren(...newContent); 213 + updateSelectionUI(); 214 + 215 + if (forceRender && !VIRTUAL_SCROLL.measured && visibleStart < visibleEnd) { 216 + requestAnimationFrame(() => { 217 + const firstRow = ui.queueList.querySelector("tr:not(.virtual-spacer)"); 218 + if (firstRow?.offsetHeight > 0) { 219 + const measured = firstRow.offsetHeight; 220 + if (measured !== VIRTUAL_SCROLL.rowHeight) { 221 + VIRTUAL_SCROLL.rowHeight = measured; 222 + const newRange = calculateVisibleRange( 223 + VIRTUAL_SCROLL.container.scrollTop, 224 + VIRTUAL_SCROLL.container.clientHeight, 225 + ); 226 + if ( 227 + newRange.start !== VIRTUAL_SCROLL.visibleStart || 228 + newRange.end !== VIRTUAL_SCROLL.visibleEnd 229 + ) { 230 + VIRTUAL_SCROLL.visibleStart = newRange.start; 231 + VIRTUAL_SCROLL.visibleEnd = newRange.end; 232 + renderVirtualRows(false); 233 + highlightCurrentTrack(); 234 + } 235 + } 236 + } 237 + VIRTUAL_SCROLL.measured = true; 238 + }); 239 + } 240 + } 241 + 242 + // clear cache and reset height measurement when queue changes 243 + function updateQueueDisplay() { 244 + ui.queueCount.textContent = state.queue.length; 245 + VIRTUAL_SCROLL.rowCache.clear(); 246 + VIRTUAL_SCROLL.rowHeight = null; 247 + VIRTUAL_SCROLL.measured = false; 248 + initVirtualScroll(); 249 + handleVirtualScroll(true); 250 + } 251 + 252 + // remove all selected rows with smart index adjustment 253 + function removeSelectedRows() { 254 + const toRemove = getSelectedIndices(); 255 + if (toRemove.length === 0) return; 256 + 257 + const toRemoveSet = new Set(toRemove); 258 + const wasCurrentTrackDeleted = toRemoveSet.has(state.queueIndex); 259 + 260 + state.queue = state.queue.filter((_, idx) => !toRemoveSet.has(idx)); 261 + 262 + if (!wasCurrentTrackDeleted && state.queueIndex >= 0) { 263 + // adjust index for deleted rows before current track 264 + state.queueIndex -= toRemove.filter((i) => i < state.queueIndex).length; 265 + } else if (wasCurrentTrackDeleted) { 266 + // find safe replacement if current track was deleted 267 + state.queueIndex = Math.min(state.queueIndex, state.queue.length - 1); 268 + if (state.queueIndex >= 0) { 269 + playTrack(state.queue[state.queueIndex]); 270 + } else { 271 + resetPlayerUI(); 272 + } 273 + } 274 + 275 + clearSelection(); 276 + updateQueue(); 277 + // ensure highlight applies with correct adjusted index 278 + highlightCurrentTrack(); 279 + } 280 + 281 + // randomly reorder queue items while preserving current track position 282 + function shuffleQueue() { 283 + const currentTrack = 284 + state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; 285 + 286 + for (let i = state.queue.length - 1; i > 0; i--) { 287 + const j = Math.floor(Math.random() * (i + 1)); 288 + [state.queue[i], state.queue[j]] = [state.queue[j], state.queue[i]]; 289 + } 290 + 291 + if (currentTrack) { 292 + state.queueIndex = state.queue.indexOf(currentTrack); 293 + } 294 + 295 + updateQueueDisplay(); 296 + } 297 + 298 + // remove all items from queue 299 + function clearQueue() { 300 + state.queue = []; 301 + state.queueIndex = -1; 302 + saveQueue(); 303 + resetPlayerUI(); 304 + updateQueueDisplay(); 305 + } 306 + 307 + // button config for queue row actions, cached to avoid repeated creation 308 + const ROW_BUTTON_CONFIG = [ 309 + { 310 + className: CLASSES.QUEUE_ACTION, 311 + label: "play", 312 + icon: ICONS.PLAY, 313 + }, 314 + { 315 + className: CLASSES.QUEUE_PLAY_NEXT, 316 + label: "play next", 317 + icon: ICONS.PLAY_NEXT, 318 + }, 319 + { 320 + className: CLASSES.QUEUE_FAVORITE, 321 + label: "favorite", 322 + icon: ICONS.FAVORITE, 323 + }, 324 + { 325 + className: CLASSES.QUEUE_MOVE_UP, 326 + label: "move up", 327 + icon: ICONS.MOVE_UP, 328 + }, 329 + { 330 + className: CLASSES.QUEUE_MOVE_DOWN, 331 + label: "move down", 332 + icon: ICONS.MOVE_DOWN, 333 + }, 334 + { 335 + className: CLASSES.QUEUE_REMOVE, 336 + label: "remove", 337 + icon: ICONS.REMOVE, 338 + }, 339 + ]; 340 + 341 + // create table row element for a song in queue 342 + function createQueueRow(song, idx) { 343 + const tr = document.createElement("tr"); 344 + tr.draggable = true; 345 + tr.setAttribute(DATA_ATTRS.INDEX, idx); 346 + tr.classList.toggle("stripe", idx % 2 === 1); 347 + 348 + // helper to create and append text cell 349 + const createTextCell = (content) => { 350 + const cell = document.createElement("td"); 351 + cell.textContent = content; 352 + tr.appendChild(cell); 353 + }; 354 + 355 + // cover art cell 356 + const coverCell = document.createElement("td"); 357 + const songArtId = shouldShowArt("artSong", song.coverArt); 358 + if (songArtId) { 359 + const img = document.createElement("img"); 360 + img.src = state.api.getCoverArtUrl(songArtId, getArtFetchSize("artSong")); 361 + img.alt = "cover"; 362 + img.className = CLASSES.QUEUE_COVER; 363 + coverCell.appendChild(img); 364 + } 365 + tr.appendChild(coverCell); 366 + 367 + // text cells 368 + createTextCell(song.title); 369 + createTextCell(song.artist || ""); 370 + createTextCell(song.album || ""); 371 + createTextCell(formatDuration(song.duration)); 372 + 373 + // action buttons cell 374 + const actionsCell = document.createElement("td"); 375 + const btnFragment = document.createDocumentFragment(); 376 + 377 + ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => { 378 + const btn = createIconButton(className, label, icon, label); 379 + if (className === CLASSES.QUEUE_FAVORITE && state.favorites.has(song.id)) { 380 + btn.classList.add(CLASSES.FAVORITED); 381 + } 382 + btnFragment.appendChild(btn); 383 + }); 384 + 385 + actionsCell.appendChild(btnFragment); 386 + tr.appendChild(actionsCell); 387 + 388 + return tr; 389 + }
+124
src/js/selection-manager.js
··· 1 + // manages row selection state for queue table (single, multi, range) 2 + class SelectionManager { 3 + constructor(container, options = {}) { 4 + this.container = container; 5 + this.selected = new Set(); 6 + this.lastSelected = null; 7 + this.rowSelector = options.rowSelector || "tr"; 8 + this.indexAttribute = options.indexAttribute || "data-index"; 9 + this.selectedClass = options.selectedClass || "selected"; 10 + this.listeners = []; 11 + } 12 + 13 + getRowIndex(row) { 14 + // get index from row element 15 + return parseInt(row.getAttribute(this.indexAttribute)); 16 + } 17 + 18 + select(index, options = {}) { 19 + const { multi = false, shift = false } = options; 20 + 21 + if (shift && this.lastSelected !== null) { 22 + // range selection from last selected 23 + const start = Math.min(this.lastSelected, index); 24 + const end = Math.max(this.lastSelected, index); 25 + for (let i = start; i <= end; i++) { 26 + this.selected.add(i); 27 + } 28 + } else if (multi) { 29 + // toggle selection with ctrl/cmd 30 + this.selected.has(index) 31 + ? this.selected.delete(index) 32 + : this.selected.add(index); 33 + } else { 34 + // single selection, clear others 35 + this.selected.clear(); 36 + this.selected.add(index); 37 + } 38 + 39 + this.lastSelected = index; 40 + this.updateUI(); 41 + this.notifyListeners(); 42 + } 43 + 44 + clear() { 45 + // clear selection 46 + this.selected.clear(); 47 + this.lastSelected = null; 48 + this.updateUI(); 49 + this.notifyListeners(); 50 + } 51 + 52 + isSelected(index) { 53 + // check if index is in selection 54 + return this.selected.has(index); 55 + } 56 + 57 + getSelected() { 58 + // return all selected indices sorted 59 + return Array.from(this.selected).sort((a, b) => a - b); 60 + } 61 + 62 + count() { 63 + // get number of selected items 64 + return this.selected.size; 65 + } 66 + 67 + updateUI() { 68 + // update dom to match selection state, only change what's different 69 + const rows = this.container.querySelectorAll(this.rowSelector); 70 + rows.forEach((row) => { 71 + const idx = this.getRowIndex(row); 72 + const isSelected = this.selected.has(idx); 73 + const hasClass = row.classList.contains(this.selectedClass); 74 + 75 + // only update dom if state changed 76 + if (isSelected !== hasClass) { 77 + isSelected 78 + ? row.classList.add(this.selectedClass) 79 + : row.classList.remove(this.selectedClass); 80 + } 81 + }); 82 + } 83 + 84 + onChange(callback) { 85 + // register listener for selection changes 86 + this.listeners.push(callback); 87 + } 88 + 89 + notifyListeners() { 90 + // notify all listeners of selection changes 91 + this.listeners.forEach((callback) => { 92 + callback(this.getSelected()); 93 + }); 94 + } 95 + 96 + setSelection(indices) { 97 + // set selection from external source (for programmatic updates) 98 + this.selected.clear(); 99 + indices.forEach((idx) => this.selected.add(idx)); 100 + this.lastSelected = indices.length > 0 ? indices[indices.length - 1] : null; 101 + this.updateUI(); 102 + this.notifyListeners(); 103 + } 104 + } 105 + 106 + // wrapper functions for legacy compatibility and convenience 107 + function selectRow(index, multiSelect = false, shiftSelect = false) { 108 + selectionManager.select(index, { multi: multiSelect, shift: shiftSelect }); 109 + } 110 + 111 + function clearSelection() { 112 + // clear all selected rows 113 + selectionManager.clear(); 114 + } 115 + 116 + function getSelectedIndices() { 117 + // get all selected row indices 118 + return selectionManager.getSelected(); 119 + } 120 + 121 + // export selection manager methods through wrapper 122 + function updateSelectionUI() { 123 + selectionManager.updateUI(); 124 + }
+103
src/js/settings.js
··· 1 + // load settings from localStorage and apply to state 2 + function loadSettings() { 3 + const saved = localStorage.getItem("tinysub_settings"); 4 + if (saved) { 5 + try { 6 + const loaded = JSON.parse(saved); 7 + state.settings = { ...state.settings, ...loaded }; 8 + } catch { 9 + // ignore parse errors, use defaults 10 + } 11 + } 12 + applySettings(); 13 + } 14 + 15 + // persist settings to localStorage 16 + function saveSettings() { 17 + localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 18 + } 19 + 20 + // apply settings to CSS custom properties 21 + function applySettings() { 22 + const mappings = { artist: "artArtist", album: "artAlbum", song: "artSong" }; 23 + Object.entries(mappings).forEach(([name, key]) => { 24 + const size = state.settings[key]; 25 + document.documentElement.style.setProperty( 26 + `--art-${name}`, 27 + size === 0 ? "0px" : `${size}px`, 28 + ); 29 + }); 30 + } 31 + 32 + // return art id for rendering, or null if size is 0 (prevents DOM creation) 33 + function shouldShowArt(artType, artId) { 34 + return state.settings[artType] > 0 ? artId : null; 35 + } 36 + 37 + // return fetch size for images at 2x the setting for retina displays 38 + function getArtFetchSize(artType) { 39 + return Math.max(state.settings[artType] * 2, 32); 40 + } 41 + 42 + // setup settings modal and controls 43 + function setupSettings() { 44 + const modal = document.getElementById("settings-modal"); 45 + const settingsBtn = document.getElementById("settings-btn"); 46 + const closeBtn = document.getElementById("close-settings-btn"); 47 + const scrobbleToggle = document.getElementById("scrobbling-toggle"); 48 + 49 + loadSettings(); 50 + scrobbleToggle.checked = state.settings.scrobbling; 51 + 52 + const artTypes = [ 53 + { name: "artist", key: "artArtist" }, 54 + { name: "album", key: "artAlbum" }, 55 + { name: "song", key: "artSong" }, 56 + ]; 57 + 58 + const sliderConfig = artTypes.map(({ name, key }) => ({ 59 + input: document.getElementById(`${name}-size`), 60 + display: document.getElementById(`${name}-size-display`), 61 + key, 62 + })); 63 + 64 + sliderConfig.forEach(({ input, display, key }) => { 65 + input.value = state.settings[key]; 66 + display.textContent = input.value; 67 + }); 68 + 69 + const toggleModal = (show) => modal.classList.toggle("hidden", !show); 70 + settingsBtn.addEventListener("click", () => toggleModal(true)); 71 + closeBtn.addEventListener("click", () => toggleModal(false)); 72 + modal.addEventListener("click", (e) => { 73 + if (e.target === modal) toggleModal(false); 74 + }); 75 + 76 + scrobbleToggle.addEventListener("change", () => { 77 + state.settings.scrobbling = scrobbleToggle.checked; 78 + saveSettings(); 79 + }); 80 + 81 + sliderConfig.forEach(({ input, display, key }) => { 82 + input.addEventListener("input", () => { 83 + display.textContent = input.value; 84 + }); 85 + 86 + input.addEventListener("change", () => { 87 + state.settings[key] = parseInt(input.value); 88 + applySettings(); 89 + saveSettings(); 90 + triggerQueueRefresh(); 91 + }); 92 + }); 93 + } 94 + 95 + // trigger refresh of queue and library when art sizes change 96 + function triggerQueueRefresh() { 97 + VIRTUAL_SCROLL.rowHeight = null; 98 + VIRTUAL_SCROLL.measured = false; 99 + VIRTUAL_SCROLL.rowCache.clear(); 100 + handleVirtualScroll(true); 101 + renderLibraryTree(); 102 + renderPlaylistsTree(); 103 + }
+51
src/js/state.js
··· 1 + // app state with api, library, queue, and user preferences 2 + const state = { 3 + api: null, 4 + library: [], 5 + playlists: [], 6 + queue: [], 7 + queueIndex: -1, 8 + favorites: new Set(), 9 + expanded: { 10 + artists: false, 11 + playlists: false, 12 + items: { 13 + artists: {}, 14 + albums: {}, 15 + }, 16 + }, 17 + settings: { 18 + scrobbling: true, 19 + artArtist: 0, 20 + artAlbum: 32, 21 + artSong: 16, 22 + }, 23 + }; 24 + 25 + // dom element references cached for quick access 26 + const ui = { 27 + loginForm: document.getElementById(DOM_IDS.LOGIN_FORM), 28 + serverInput: document.getElementById(DOM_IDS.SERVER_INPUT), 29 + usernameInput: document.getElementById(DOM_IDS.USERNAME_INPUT), 30 + passwordInput: document.getElementById(DOM_IDS.PASSWORD_INPUT), 31 + authError: document.getElementById(DOM_IDS.AUTH_ERROR), 32 + artistsTree: document.getElementById(DOM_IDS.LIBRARY_TREE), 33 + playlistsTree: document.getElementById(DOM_IDS.PLAYLISTS_TREE), 34 + queueList: document.getElementById(DOM_IDS.QUEUE_LIST), 35 + queueCount: document.getElementById(DOM_IDS.QUEUE_COUNT), 36 + coverArt: document.getElementById(DOM_IDS.COVER_ART), 37 + trackTitle: document.getElementById(DOM_IDS.TRACK_TITLE), 38 + trackArtist: document.getElementById(DOM_IDS.TRACK_ARTIST), 39 + trackLyric: document.getElementById(DOM_IDS.TRACK_LYRIC), 40 + player: document.getElementById(DOM_IDS.PLAYER), 41 + playBtn: document.getElementById(DOM_IDS.PLAY_BTN), 42 + prevBtn: document.getElementById(DOM_IDS.PREV_BTN), 43 + nextBtn: document.getElementById(DOM_IDS.NEXT_BTN), 44 + progress: document.getElementById(DOM_IDS.PROGRESS), 45 + timeDisplay: document.getElementById(DOM_IDS.TIME_DISPLAY), 46 + logoutBtn: document.getElementById(DOM_IDS.LOGOUT_BTN), 47 + settingsBtn: document.getElementById("settings-btn"), 48 + sectionToggles: document.querySelectorAll(`.${DOM_IDS.SECTION_TOGGLE}`), 49 + shuffleBtn: document.getElementById(DOM_IDS.SHUFFLE_BTN), 50 + clearBtn: document.getElementById(DOM_IDS.CLEAR_BTN), 51 + };
+91
src/js/ui.js
··· 1 + // update play button icon based on playback state 2 + const updatePlayIcon = () => { 3 + const iconPath = ui.player.paused ? ICONS.PLAY : ICONS.PAUSE; 4 + const label = ui.player.paused ? "play" : "pause"; 5 + ui.playBtn.innerHTML = `<img src="${iconPath}" alt="${label}" />`; 6 + }; 7 + 8 + // create tree item with cover art and action buttons 9 + function createTreeItem( 10 + name, 11 + coverArtId, 12 + onToggle, 13 + onAdd, 14 + onPlayNext, 15 + artType, 16 + itemId, 17 + ) { 18 + const li = createElement("li"); 19 + const div = createElement("div", { className: CLASSES.TREE_ITEM }); 20 + 21 + if (itemId) li.dataset.itemId = itemId; 22 + 23 + const linkChildren = []; 24 + if (coverArtId) { 25 + linkChildren.push( 26 + createElement("img", { 27 + attributes: { 28 + src: state.api.getCoverArtUrl(coverArtId, getArtFetchSize(artType)), 29 + alt: "cover", 30 + loading: "lazy", 31 + }, 32 + }), 33 + ); 34 + } 35 + linkChildren.push(createElement("span", { textContent: name })); 36 + 37 + const linkEl = createElement("a", { 38 + className: onToggle ? CLASSES.TREE_TOGGLE : CLASSES.TREE_NAME, 39 + attributes: { href: "#" }, 40 + listeners: { 41 + click: (e) => { 42 + e.preventDefault(); 43 + onToggle ? onToggle(li) : onAdd?.(); 44 + }, 45 + }, 46 + children: linkChildren, 47 + }); 48 + 49 + div.appendChild(linkEl); 50 + if (onPlayNext) 51 + div.appendChild( 52 + createIconButton( 53 + "tree-action", 54 + "play next", 55 + ICONS.PLAY_NEXT, 56 + "play next", 57 + onPlayNext, 58 + ), 59 + ); 60 + if (onAdd) 61 + div.appendChild( 62 + createIconButton("tree-action", "add", ICONS.ADD, "add", onAdd), 63 + ); 64 + 65 + li.appendChild(div); 66 + return li; 67 + } 68 + 69 + const SECTION_RENDERERS = { 70 + artists: renderLibraryTree, 71 + playlists: renderPlaylistsTree, 72 + }; 73 + 74 + // toggle library section expand/collapse 75 + function handleSectionToggle(e) { 76 + e.preventDefault(); 77 + const section = e.target.dataset.section; 78 + state.expanded[section] = !state.expanded[section]; 79 + if ( 80 + state.expanded[section] && 81 + typeof state.expanded.items[section] === "undefined" 82 + ) { 83 + state.expanded.items[section] = {}; 84 + } 85 + SECTION_RENDERERS[section]?.(); 86 + } 87 + 88 + // toggle auth modal 89 + const toggleAuthModal = (show) => { 90 + document.getElementById(DOM_IDS.AUTH_MODAL).classList.toggle("hidden", !show); 91 + };
+133
src/js/utils.js
··· 1 + // format seconds as mm:ss string 2 + function formatDuration(seconds) { 3 + if (!seconds || !Number.isFinite(seconds)) return "0:00"; 4 + const minutes = Math.floor(seconds / 60); 5 + return `${minutes}:${Math.floor(seconds % 60) 6 + .toString() 7 + .padStart(2, "0")}`; 8 + } 9 + 10 + // get row index from data attribute 11 + function getRowIndex(rowEl, attrName = "data-index") { 12 + return parseInt(rowEl.getAttribute(attrName)); 13 + } 14 + 15 + // find closest row element 16 + function getClosestRow(el, rowSelector = "tr") { 17 + return el.closest(rowSelector); 18 + } 19 + 20 + // generic element creator 21 + function createElement(tag, config = {}) { 22 + const { 23 + className, 24 + textContent, 25 + html, 26 + attributes = {}, 27 + listeners = {}, 28 + children = [], 29 + } = config; 30 + const el = document.createElement(tag); 31 + if (className) el.className = className; 32 + if (textContent) el.textContent = textContent; 33 + if (html) el.innerHTML = html; 34 + Object.entries(attributes).forEach(([key, value]) => 35 + el.setAttribute(key, value), 36 + ); 37 + Object.entries(listeners).forEach(([event, handler]) => 38 + el.addEventListener(event, handler), 39 + ); 40 + children.forEach((child) => el.appendChild(child)); 41 + return el; 42 + } 43 + 44 + // create a button with icon 45 + function createIconButton(className, ariaLabel, iconPath, iconAlt, onCallback) { 46 + const btn = createElement("button", { 47 + className, 48 + attributes: { "aria-label": ariaLabel }, 49 + children: [ 50 + createElement("img", { attributes: { src: iconPath, alt: iconAlt } }), 51 + ], 52 + }); 53 + 54 + if (onCallback) { 55 + btn.addEventListener("click", async (e) => { 56 + e.preventDefault(); 57 + btn.classList.add(CLASSES.DISABLED); 58 + try { 59 + await onCallback(); 60 + } finally { 61 + btn.classList.remove(CLASSES.DISABLED); 62 + } 63 + }); 64 + } 65 + 66 + return btn; 67 + } 68 + 69 + // remove classes from only elements that have them 70 + function clearRowClasses(container, rowSelector, classNames) { 71 + const classes = Array.isArray(classNames) ? classNames : [classNames]; 72 + container.querySelectorAll(rowSelector).forEach((row) => { 73 + classes.forEach((cls) => row.classList.remove(cls)); 74 + }); 75 + } 76 + 77 + // add or remove class from specific rows 78 + function updateRowClass(container, indices, className, add = true) { 79 + const indexSet = new Set(indices); 80 + container.querySelectorAll("tr").forEach((row) => { 81 + const idx = getRowIndex(row); 82 + row.classList.toggle(className, indexSet.has(idx) === add); 83 + }); 84 + } 85 + 86 + function isValidQueueIndex(index, queueLength) { 87 + return index >= 0 && index < queueLength; 88 + } 89 + 90 + // move items within queue and maintain proper indices 91 + function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) { 92 + const { onSelectionChange, onQueueChange } = callbacks; 93 + const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b); 94 + const movedItems = sortedIndices.map((i) => queue[i]); 95 + 96 + // remove items in reverse order to avoid index shifting 97 + for ( 98 + let i = sortedIndices[sortedIndices.length - 1]; 99 + i >= sortedIndices[0]; 100 + i-- 101 + ) { 102 + if (selectedIndices.includes(i)) queue.splice(i, 1); 103 + } 104 + 105 + // normalize insert position for removed items 106 + insertPos -= sortedIndices.filter((i) => i < insertPos).length; 107 + 108 + // insert moved items at target position 109 + queue.splice(insertPos, 0, ...movedItems); 110 + 111 + // update queue index for moved/removed items 112 + if (state.queueIndex >= 0) { 113 + if (selectedIndices.includes(state.queueIndex)) { 114 + const positionInMoved = sortedIndices.filter( 115 + (i) => i < state.queueIndex, 116 + ).length; 117 + state.queueIndex = insertPos + positionInMoved; 118 + } else { 119 + const newIndex = 120 + state.queueIndex - 121 + sortedIndices.filter((i) => i < state.queueIndex).length; 122 + state.queueIndex = 123 + insertPos <= newIndex ? newIndex + movedItems.length : newIndex; 124 + } 125 + } 126 + 127 + saveQueue(); 128 + 129 + // batch DOM updates together to avoid reflows 130 + if (onSelectionChange) 131 + onSelectionChange(sortedIndices.map((_, i) => insertPos + i)); 132 + if (onQueueChange) onQueueChange(); 133 + }
+59
src/js/validation.js
··· 1 + // build validation response object 2 + const buildValidation = (valid, value, error) => ({ 3 + valid, 4 + ...(valid ? { value } : { error }), 5 + }); 6 + 7 + // check if id is present and valid type 8 + function validateId(value, fieldName = "ID") { 9 + const isValid = 10 + value && (typeof value === "string" || typeof value === "number"); 11 + return buildValidation(isValid, value, `${fieldName} is required`); 12 + } 13 + 14 + // validate server URL is valid HTTP(S) format 15 + function validateServerUrl(url) { 16 + if (!url || typeof url !== "string") { 17 + return buildValidation(false, null, "Server URL is required"); 18 + } 19 + 20 + const trimmed = url.trim(); 21 + if (!trimmed) { 22 + return buildValidation(false, null, "Server URL cannot be empty"); 23 + } 24 + 25 + try { 26 + const urlObj = new URL(trimmed); 27 + const isValid = ["http:", "https:"].includes(urlObj.protocol); 28 + return buildValidation( 29 + isValid, 30 + trimmed, 31 + isValid ? null : "Server URL must use HTTP or HTTPS", 32 + ); 33 + } catch { 34 + return buildValidation(false, null, "Invalid URL format"); 35 + } 36 + } 37 + 38 + // validate username and password meet requirements 39 + function validateCredentials(username, password) { 40 + const errors = []; 41 + 42 + if (!username || typeof username !== "string" || !username.trim()) { 43 + errors.push("Username is required"); 44 + } else if (username.length > 255) { 45 + errors.push("Username is too long (max 255 characters)"); 46 + } 47 + 48 + if (!password || typeof password !== "string" || !password) { 49 + errors.push("Password is required"); 50 + } else if (password.length > 1000) { 51 + errors.push("Password is too long"); 52 + } 53 + 54 + if (errors.length > 0) { 55 + return buildValidation(false, null, errors.join("; ")); 56 + } 57 + 58 + return buildValidation(true, { username: username.trim(), password }); 59 + }
src/static/famfamfam-silk/accept.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/anchor.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_cascade.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_double.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_form.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_form_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_form_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_form_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_form_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_get.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_home.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_osx.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_osx_terminal.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_put.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_side_boxes.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_side_contract.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_side_expand.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_side_list.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_side_tree.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_split.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_tile_horizontal.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_tile_vertical.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_view_columns.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_view_detail.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_view_gallery.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_view_icons.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_view_list.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_view_tile.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_xp.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/application_xp_terminal.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_branch.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_divide.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_down.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_in.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_inout.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_join.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_left.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_merge.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_out.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_redo.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_refresh.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_refresh_small.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_right.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_rotate_anticlockwise.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_rotate_clockwise.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_switch.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_turn_left.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_turn_right.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_undo.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/arrow_up.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/asterisk_orange.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/asterisk_yellow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/attach.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_bronze_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_bronze_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_bronze_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_gold_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_gold_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_gold_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_silver_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_silver_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/award_star_silver_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_put.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/basket_remove.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bell.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bell_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bell_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bell_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bell_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bell_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bin.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bin_closed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bin_empty.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bomb.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_addresses.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_next.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_open.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/book_previous.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/box.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/brick_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bricks.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/briefcase.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bug_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/building_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_arrow_bottom.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_arrow_down.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_arrow_top.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_arrow_up.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_black.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_disk.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_feed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_green.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_orange.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_picture.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_pink.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_purple.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_star.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_toggle_minus.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_toggle_plus.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_white.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_wrench.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/bullet_yellow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cake.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calculator.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calculator_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calculator_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calculator_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calculator_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calculator_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_view_day.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_view_month.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/calendar_view_week.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/camera_small.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cancel.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/car.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/car_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/car_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_put.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cart_remove.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd_burn.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd_eject.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cd_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_bar.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_bar_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_bar_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_bar_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_bar_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_bar_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_curve_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_line.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_line_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_line_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_line_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_line_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_line_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_organisation.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_organisation_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_organisation_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_pie.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_pie_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_pie_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_pie_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_pie_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/chart_pie_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_pause.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_play.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/clock_stop.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cog.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cog_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cog_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cog_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cog_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cog_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/coins.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/coins_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/coins_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/color_swatch.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/color_wheel.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comment.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comment_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comment_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comment_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comments.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comments_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/comments_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/compress.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/computer_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/connect.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/contrast.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/contrast_decrease.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/contrast_high.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/contrast_increase.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/contrast_low.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_eject.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_eject_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_end.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_end_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_equalizer.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_equalizer_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_fastforward.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_fastforward_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_pause.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_pause_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_play.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_play_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_repeat.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_repeat_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_rewind.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_rewind_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_start.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_start_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_stop.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/control_stop_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/controller.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/controller_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/controller_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/controller_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/creditcards.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cross.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/css.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/css_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/css_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/css_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/css_valid.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cup_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cursor.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cut.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/cut_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_connect.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_refresh.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_save.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/database_table.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_next.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/date_previous.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/disconnect.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/disk.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/disk_multiple.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/door.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/door_in.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/door_open.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/door_out.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drink.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drink_empty.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_burn.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_cd.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_cd_empty.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_disk.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_network.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_rename.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_user.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/drive_web.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/dvd_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_attach.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_open.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/email_open_image.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_evilgrin.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_grin.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_happy.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_smile.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_surprised.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_tongue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_unhappy.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_waii.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/emoticon_wink.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/error_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/error_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/error_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/exclamation.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/eye.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_disk.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/feed_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/female.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/film_save.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/find.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_green.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_orange.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_pink.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_purple.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/flag_yellow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_bell.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_brick.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_bug.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_camera.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_database.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_explore.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_feed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_find.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_heart.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_image.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_lightbulb.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_page.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_page_white.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_palette.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_picture.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_star.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_table.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_user.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/folder_wrench.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/font.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/font_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/font_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/font_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/group_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/heart.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/heart_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/heart_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/help.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/hourglass.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/hourglass_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/hourglass_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/hourglass_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/hourglass_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/house.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/house_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/house_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/html.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/html_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/html_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/html_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/html_valid.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/image.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/image_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/image_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/image_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/image_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/images.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/information.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ipod.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ipod_cast.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ipod_cast_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ipod_cast_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ipod_sound.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/joystick.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/joystick_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/joystick_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/joystick_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/key_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/key_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/key_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/keyboard.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/keyboard_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/keyboard_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/keyboard_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layers.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_content.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_header.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/layout_sidebar.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightbulb.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightbulb_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightbulb_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightbulb_off.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightning_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightning_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lightning_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock_break.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lock_open.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry_flatbed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/lorry_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/magifier_zoom_out.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/magnifier.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/magnifier_zoom_in.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/male.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/map.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/map_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/map_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/map_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/map_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/map_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_bronze_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_bronze_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_bronze_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_bronze_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_bronze_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_gold_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_gold_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_gold_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_gold_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_gold_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_silver_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_silver_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_silver_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_silver_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/medal_silver_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money_dollar.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money_euro.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money_pound.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/money_yen.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/monitor_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/mouse.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/mouse_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/mouse_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/mouse_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/music.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/new.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/newspaper.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/newspaper_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/newspaper_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/newspaper_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/newspaper_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/note.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/note_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/note_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/note_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/note_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/note_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/overlays.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/package.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/package_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/package_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/package_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/package_green.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/package_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_attach.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_code.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_copy.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_excel.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_find.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_green.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_paintbrush.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_paste.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_refresh.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_save.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_acrobat.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_actionscript.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_c.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_camera.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_cd.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_code.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_code_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_coldfusion.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_compressed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_copy.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_cplusplus.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_csharp.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_cup.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_database.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_dvd.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_excel.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_find.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_flash.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_freehand.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_get.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_h.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_horizontal.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_medal.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_office.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_paint.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_paintbrush.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_paste.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_php.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_picture.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_powerpoint.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_put.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_ruby.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_stack.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_star.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_swoosh.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_text.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_text_width.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_tux.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_vector.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_visualstudio.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_width.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_word.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_world.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_wrench.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_white_zip.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_word.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/page_world.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/paintbrush.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/paintcan.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/palette.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/paste_plain.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/paste_word.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pencil.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pencil_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pencil_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pencil_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/phone.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/phone_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/phone_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/phone_sound.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/photo.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/photo_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/photo_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/photo_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/photos.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_empty.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/picture_save.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pictures.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pilcrow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pill.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pill_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pill_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/pill_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_disabled.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/plugin_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/printer.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/printer_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/printer_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/printer_empty.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/printer_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rainbow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_disk.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_magnify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_picture.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_user.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/report_word.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/resultset_first.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/resultset_last.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/resultset_next.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/resultset_previous.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rosette.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rss.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rss_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rss_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rss_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/rss_valid.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_get.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/ruby_put.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_code.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_code_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_palette.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/script_save.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_chart.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_compressed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_connect.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_database.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/server_uncompressed.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shading.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_align_bottom.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_align_center.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_align_left.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_align_middle.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_align_right.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_align_top.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_flip_horizontal.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_flip_vertical.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_group.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_handles.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_move_back.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_move_backwards.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_move_forwards.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_move_front.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_rotate_anticlockwise.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_rotate_clockwise.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_square_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shape_ungroup.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shield.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shield_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shield_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/shield_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sitemap.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sitemap_color.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sound.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sound_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sound_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sound_low.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sound_mute.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sound_none.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/spellcheck.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_8ball.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_basketball.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_football.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_golf.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_raquet.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_shuttlecock.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_soccer.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sport_tennis.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/star.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/status_away.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/status_busy.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/status_offline.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/status_online.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/stop.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/style.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/style_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/style_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/style_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/style_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/sum.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tab.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tab_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tab_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tab_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tab_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_gear.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_multiple.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_refresh.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_relationship.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_row_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_row_insert.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_save.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/table_sort.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_blue_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_blue_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_blue_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_green.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_orange.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_pink.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_purple.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tag_yellow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/telephone_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/television.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/television_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/television_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_align_center.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_align_justify.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_align_left.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_align_right.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_allcaps.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_bold.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_columns.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_dropcaps.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_heading_1.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_heading_2.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_heading_3.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_heading_4.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_heading_5.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_heading_6.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_horizontalrule.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_indent.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_indent_remove.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_italic.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_kerning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_letter_omega.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_letterspacing.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_linespacing.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_list_bullets.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_list_numbers.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_lowercase.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_padding_bottom.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_padding_left.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_padding_right.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_padding_top.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_replace.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_signature.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_smallcaps.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_strikethrough.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_subscript.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_superscript.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_underline.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/text_uppercase.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/textfield.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/textfield_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/textfield_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/textfield_key.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/textfield_rename.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/thumb_down.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/thumb_up.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tick.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/time.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/time_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/time_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/time_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/timeline_marker.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit_blue.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/transmit_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/tux.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_comment.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_female.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_gray.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_green.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_orange.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_red.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/user_suit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vcard.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vcard_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vcard_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vcard_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vector.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vector_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/vector_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/wand.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/weather_clouds.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/weather_cloudy.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/weather_lightning.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/weather_rain.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/weather_snow.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/weather_sun.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/webcam.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/webcam_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/webcam_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/webcam_error.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/world.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/world_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/world_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/world_edit.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/world_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/world_link.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/wrench.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/wrench_orange.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/xhtml.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/xhtml_add.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/xhtml_delete.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/xhtml_go.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/xhtml_valid.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/zoom.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/zoom_in.png

This is a binary file and will not be displayed.

src/static/famfamfam-silk/zoom_out.png

This is a binary file and will not be displayed.

src/static/subsonic.png

This is a binary file and will not be displayed.