├── .gitignore ├── README.md ├── changelog.md ├── fonts ├── Guifx v2 Transports.ttf ├── alternateLight.otf ├── antialiasBold.otf ├── antialiasLight.otf ├── antialiasRegular.otf ├── antialiasThin.otf ├── fontawesome-webfont.ttf ├── marlett.ttf └── windows-system-fonts │ ├── For Windows Vista and XP only!.txt │ ├── README.md │ ├── segoeui.ttf │ └── seguisb.ttf ├── foo_spider_monkey_panel └── docs │ └── js │ └── foo_spider_monkey_panel.js ├── georgia-theme.js ├── images ├── icons │ ├── original │ │ ├── 32 │ │ │ ├── library.png │ │ │ ├── lyrics.png │ │ │ ├── playlist.png │ │ │ ├── properties.png │ │ │ ├── settings.png │ │ │ └── star.png │ │ └── 64 │ │ │ ├── library.png │ │ │ ├── lyrics.png │ │ │ ├── playlist.png │ │ │ ├── properties.png │ │ │ ├── settings.png │ │ │ └── star.png │ └── updated │ │ ├── 32 │ │ ├── library.png │ │ ├── lyrics.png │ │ ├── playlist.png │ │ ├── properties.png │ │ ├── settings.png │ │ └── star.png │ │ └── 64 │ │ ├── library.png │ │ ├── lyrics.png │ │ ├── playlist.png │ │ ├── properties.png │ │ ├── settings.png │ │ └── star.png ├── last-fm-28.png ├── last-fm-36.png ├── last-fm-red-28.png └── last-fm-red-36.png ├── js ├── CaTRoX_QWR │ ├── Common.js │ ├── Control_Button.js │ ├── Control_ContextMenu.js │ ├── Control_List.js │ ├── Control_Scrollbar.js │ ├── Panel_Library.js │ ├── Panel_Playlist.js │ ├── Utility_LinkedList.js │ ├── html │ │ ├── GroupPresetsMngr.html │ │ ├── MsgBox.html │ │ ├── PopupWithCheckBox.html │ │ ├── styles10.css │ │ └── styles7.css │ ├── lodash-new.js │ └── lodash.min.js ├── color.js ├── configuration.js ├── defaults.js ├── georgia-main.js ├── helpers.js ├── hyperlinks.js ├── image-caching.js ├── lyrics.js ├── playlist-history.js ├── settings.js ├── themes.js ├── ui-components.js └── volume.js └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-workspace 2 | *.sublime-project 3 | *.code-workspace 4 | /.vscode/* 5 | 6 | .prettierignore 7 | *.fcl 8 | georgia-main.backup.js 9 | *Scratch Pad.txt 10 | *dropbox.attr 11 | 12 | # foo_spider_monkey_panel/ 13 | jsconfig.json 14 | georgia-*.json* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Georgia 2 | [![donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=9LW4ABRYXG2DY&source=url) 3 | 4 | Georgia is a theme for foobar2000 designed to change dynamically based on album art. It’s original purpose was to be used on a HTPC so it has large album artwork, plenty of logos and other eye candy. Over time it has evolved to add a playlist and other features which make it perfectly suitable for using on a desktop as well. It can be run in a window and it resizes pretty well, but it usually looks best when run maximized to show off all your big beautiful artwork. My hope is it provides a modern take on the joy of holding a vinyl sleeve or looking through a CD booklet. 5 | 6 | Dark Mode: 7 | ![Lights](https://i.imgur.com/Eu9Q1Mv.jpg) 8 | Light Mode: 9 | ![BladeRunner 2049](https://i.imgur.com/pspQQeb.png) 10 | Gallery of images [here](https://imgur.com/a/TtjUS) with explanation of some of the features. 11 | 12 | ## Documentation 13 | 14 | Detailed documentation and installation instructions are available [here](https://kbuffington.github.io/Georgia/). -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ### v2.0.0 - 2021-03-?? 2 | Rolls up all changes from the betas below plus 3 | - Transport control settings added to config file 4 | - Fixed display issues with Playlist >> Group Presets manager 5 | - Now caching artist logos 6 | - Improved readability/contrast of text in playlist/library when light/bright artwork is displayed 7 | 8 | ### v2.0.0b4 - 2021-02-21 9 | - Lyrics filename patterns can be specified in the config file 10 | - Config file can be edited/reset from the settings menu 11 | - Artwork is no longer reloaded/parsed unnecessarily when changing tracks quickly (prevents flashes of wrong theme color) 12 | - Added new icon set (and menu option to select) based on icons created by @Zephyr0ck 13 | 14 | ### v2.0.0b3 - 2021-01-30 15 | - Now works with foo_ui_hacks to show min/max/close buttons when applicable and moves UI elements accordingly 16 | - Fixed regression with Queue'd items not showing in playlist 17 | - Transport button spacing now configurable (thanks @notsigma) 18 | - Allow filtering out of cd.jpgs from showing with rest of artwork 19 | - Improved config version upgrades 20 | - Added option for showing full date in playlist header 21 | - Lots of code cleanup 22 | 23 | ### v2.0.0b2 - 2021-01-15 24 | - Updating config files from previous versions more robust 25 | - Adding some new properties/settings 26 | - Showing release country flag if the tf.releaseCountry field is set 27 | - Replicated theme background on theme startup so on_paint never shows white 28 | - Stopped text now shows "foobar" and version. This is configurable and you can set it back to "foobar plays music" which was the old default 29 | 30 | ### v2.0.0b1 - 2021-01-06 31 | - foo_jscript_panel replaced with foo_spider_monkey_panel 32 | - Simplified script initialization (no more pasting contents of Georgia.txt into Configuration panel after initial setup time) 33 | - Automatically generating and reading preferences from georgia-config.jsonc 34 | - When using hyperlinks to search, if current playing song is in results, it will show as playing 35 | - Added georgia-config.jsonc file to store preferences outside foobar 36 | - Updating track information in when `on_playback_dynamic_info_track` is called. 37 | - Improve visibility of progress bar when art primary color is too dark (i.e. close to the background color) 38 | - Theme update checks happen once a day if enabled 39 | 40 | ### v1.1.9 - 2020-07-10 41 | - Fix library panel not showing tracks with foo_jscript_panel 2.4.x 42 | - Allow specifying a custom cdart filename 43 | - No longer show "0000" for date 44 | - Allow override of playlist row_h 45 | - Fix issues related to font-sizes in playlist header 46 | - Prevent labels in playlist header from being drawn over group info 47 | - Handle hyperlinks searching for albums with editions listed 48 | 49 | ### v1.1.8 - 2020-05-09 50 | - Random now actually randomizes playlist 51 | - Fixed volume control issues 52 | - Improved tooltip handling for buttons 53 | - Fixed issues with expanded volume bar disappearing and it's appearance in 4k mode 54 | - Fixed crash when deleting last playlist 55 | - CD Rotation values were bogus 56 | - Refactored all menus using new `Menu` helper class, which cut menu code length in half and made adding new options much easier 57 | - Fixed crash when using weblinks 58 | - Playlist row and header fonts are scalable through Options >> Playlist settings 59 | - Option to move transport controls below artwork 60 | - Visual improvements in 4k mode (ensuring spacing between elements is scaled correctly) 61 | - Adding Georgia entries to "Help" menu to quickly debug if the theme is installed correctly 62 | - Added tooltips on hovering over timeline 63 | - Adjust menu font sizes through options menu 64 | - Adjust transport button sizes through options menu 65 | 66 | ### v1.1.7 - 2020-04-11 67 | - Invert logos when theme primary color is dark (requires foo_jscript_panel v2.3.6) 68 | - Fixed crash when clicking the hyperlink to upgrade. Sorry! 69 | - Fixed crash when managing grouping presets 70 | - Added volume control 71 | - Album labels in playlist are now hyperlinks 72 | - Fixed some date timezone issues 73 | - Improved playlist look when tags don't have a genre 74 | 75 | ### v1.1.6 - 2019-11-13 76 | - Fixed startup crashes when creating buttons 77 | - Drag & Drop issues 78 | - Simplified date and timezone handling 79 | - Cleaned up georgia.txt 80 | - Improved support for foo_youtube 81 | 82 | ### v1.1.5 - 2019-10-22 83 | - Fixes for foo_jscript_panel 2.3.x 84 | - Removed unneeded files 85 | - Updating fonts 86 | 87 | ### v1.1.4 - 2019-08-29 88 | - Add check for updates 89 | 90 | ### v1.1.3 - 2019-08-28 91 | - Fixed broken dates 92 | - Fixed anti-aliasing on elapsed time when playlist is shown 93 | 94 | ### v1.1.2 - 2019-08-27 95 | - Playlist should always draw correctly now 96 | - Dates should never show as "0000" 97 | - Year now uses $if3(%original release date%,%originaldate%,%date%) 98 | - ArtCaching was using the wrong values to scale. Corrected 99 | - Ticks on the timeline should never show overlap the album art 100 | 101 | ### v1.1.1 - 2019-08-11 102 | - Crash on startup when display playlist on startup set 103 | 104 | ### v1.1.0 - 2019-08-10 105 | - Dark mode (new default)! Switch between the two in the options menu 106 | - A ton more 4k fixes 107 | - reiniting playlist when 4k mode switches to avoid scrollbar issues 108 | - accurate date difference code based on human accepted norms of what a date difference is (i.e. 1 month ago) 109 | - correctly handling forbidden characters when attempting to find artwork/files 110 | - better sorting of results when clicking on hyperlinks 111 | - searching dates by year only 112 | - Fixed a bunch of issues with Multi-channel display 113 | - Highlight colors in library/playlist should still allow text to be legible 114 | - Drastically reduced console spam 115 | 116 | ### v1.0.1 - 2019-01-23 117 | - Fix some 4k scaling issues 118 | - auto load library 10s after startup for better response time 119 | - fix crash in jscript 2.2.0+ 120 | - variable font sizing for artist string 121 | 122 | ### v1.0.0 - First official release -------------------------------------------------------------------------------- /fonts/Guifx v2 Transports.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/Guifx v2 Transports.ttf -------------------------------------------------------------------------------- /fonts/alternateLight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/alternateLight.otf -------------------------------------------------------------------------------- /fonts/antialiasBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/antialiasBold.otf -------------------------------------------------------------------------------- /fonts/antialiasLight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/antialiasLight.otf -------------------------------------------------------------------------------- /fonts/antialiasRegular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/antialiasRegular.otf -------------------------------------------------------------------------------- /fonts/antialiasThin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/antialiasThin.otf -------------------------------------------------------------------------------- /fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /fonts/marlett.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/marlett.ttf -------------------------------------------------------------------------------- /fonts/windows-system-fonts/For Windows Vista and XP only!.txt: -------------------------------------------------------------------------------- 1 | Only install these fonts if you are not using Windows 7 or later. 2 | 3 | Included here are Segoe UI and Segoe UI Semibold. 4 | 5 | They are strictly provided for legacy purposes. Installing them could overwrite Windows system fonts. -------------------------------------------------------------------------------- /fonts/windows-system-fonts/README.md: -------------------------------------------------------------------------------- 1 | ### Only install these fonts if you are not using Windows 7 or later. 2 | 3 | Included here are **Segoe UI** and **Segoe UI Semibold**. 4 | 5 | They are strictly provided for legacy purposes. Installing them could overwrite Windows system fonts. -------------------------------------------------------------------------------- /fonts/windows-system-fonts/segoeui.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/windows-system-fonts/segoeui.ttf -------------------------------------------------------------------------------- /fonts/windows-system-fonts/seguisb.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/fonts/windows-system-fonts/seguisb.ttf -------------------------------------------------------------------------------- /georgia-theme.js: -------------------------------------------------------------------------------- 1 | const panelVersion = window.GetProperty('_theme_version (do not hand edit!)', '2.0.3'); 2 | window.DefineScript('Georgia', {author: 'Mordred', version: panelVersion, features: {drag_n_drop: true} }); 3 | 4 | const basePath = fb.ProfilePath + 'georgia\\'; 5 | 6 | function loadAsyncFile(filePath) { 7 | return new Promise(resolve => { 8 | setTimeout(() => { 9 | include(filePath); 10 | resolve(); 11 | }, 0); 12 | }) 13 | } 14 | 15 | const loadAsync = window.GetProperty('Load Theme Asynchronously', true); 16 | async function includeFiles(fileList) { 17 | if (loadAsync) { 18 | let startTime = Date.now(); 19 | const refreshTime = 16; // ~60Hz 20 | for (let i = 0; i < fileList.length; i++) { 21 | loadStrs.fileName = fileList[i] + ' ...'; 22 | loadStrs.fileIndex = i; 23 | const currentTime = Date.now(); 24 | if (currentTime - startTime > refreshTime) { 25 | startTime = currentTime; 26 | window.Repaint(); 27 | } 28 | await loadAsyncFile(basePath + fileList[i]); 29 | } 30 | } else { 31 | fileList.forEach(filePath => include(filePath)); 32 | } 33 | } 34 | 35 | const loadStrs = { 36 | loading: 'Loading:', 37 | fileName: '', 38 | fileIndex: 0, 39 | }; 40 | const startTime = Date.now(); 41 | const fileList = [ 42 | // 'js\\CaTRoX_QWR\\lodash.min.js', 43 | 'js\\CaTRoX_QWR\\lodash-new.js', 44 | 'js\\configuration.js', // reads/write from config file. The actual configuration values are specified in globals.js 45 | 'js\\helpers.js', 46 | 'js\\CaTRoX_QWR\\Common.js', 47 | 'js\\defaults.js', // used in settings.js 48 | 'js\\hyperlinks.js', // used in settings.js 49 | 'js\\settings.js', // must be below hyperlinks.js and Common.js 50 | 'js\\CaTRoX_QWR\\Utility_LinkedList.js', 51 | 'js\\CaTRoX_QWR\\Control_ContextMenu.js', 52 | 'js\\CaTRoX_QWR\\Control_Scrollbar.js', 53 | 'js\\CaTRoX_QWR\\Control_List.js', 54 | 'js\\CaTRoX_QWR\\Panel_Playlist.js', 55 | 'js\\CaTRoX_QWR\\Panel_Library.js', 56 | 'js\\CaTRoX_QWR\\Control_Button.js', 57 | 'js\\color.js', 58 | 'js\\themes.js', 59 | 'js\\volume.js', 60 | 'js\\image-caching.js', 61 | 'js\\ui-components.js', 62 | 'js\\lyrics.js', 63 | 'js\\playlist-history.js', 64 | 'js\\georgia-main.js' 65 | ]; 66 | includeFiles(fileList).then(() => { 67 | console.log(`Georgia loaded in ${Date.now() - startTime}ms`); 68 | 69 | if (pref.checkForUpdates) { 70 | scheduleUpdateCheck(0); 71 | } 72 | }); 73 | 74 | // this function will be overridden once the theme loads 75 | function on_paint(gr) { 76 | const RGB = (r, g, b) => { return (0xff000000 | (r << 16) | (g << 8) | (b)); } 77 | const scaleForDisplay = (number) => { return is_4k ? number * 2 : number }; 78 | const darkMode = window.GetProperty('Use Dark Theme', true); 79 | const col = {}; 80 | 81 | if (darkMode) { 82 | col.bg = RGB(50, 54, 57); 83 | col.menu_bg = RGB(23, 23, 23); 84 | col.now_playing = RGB(255, 255, 255); 85 | col.progressFill = RGB(255,255,255); 86 | } else { 87 | col.bg = RGB(185, 185, 185); 88 | col.menu_bg = RGB(54, 54, 54); 89 | col.now_playing = RGB(0, 0, 0); 90 | col.progressFill = RGB(0, 0, 40); 91 | } 92 | const use_4k = window.GetProperty('Detect 4k', 'auto'); 93 | const ww = window.Width; 94 | const wh = window.Height; 95 | 96 | if (use_4k === 'always') { 97 | is_4k = true; 98 | } else if (use_4k === 'auto' && (ww > 3000 || wh > 1400)) { 99 | is_4k = true; 100 | } else { 101 | is_4k = false; 102 | } 103 | gr.SetSmoothingMode(3); 104 | const menuHeight = scaleForDisplay(160); 105 | gr.FillSolidRect(0, menuHeight, ww, wh - menuHeight, col.bg); 106 | gr.FillSolidRect(0, 0, ww, menuHeight, col.menu_bg); 107 | 108 | const font = (name, size, style) => { 109 | var font; 110 | try { 111 | font = gdi.Font(name, Math.round(scaleForDisplay(size)), style); 112 | } catch (e) { 113 | console.log('Failed to load font >>>', name, size, style); 114 | } 115 | return font; 116 | } 117 | const fontLight = 'HelveticaNeueLT Pro 45 Lt'; 118 | const fontBold = 'HelveticaNeueLT Pro 65 Md'; 119 | const ft_lower = font(fontLight, 30, 0); 120 | const ft_lower_bold = font(fontBold, 30, 0); 121 | const lowerBarTop = wh - scaleForDisplay(80); 122 | const loadingWidth = Math.ceil(gr.MeasureString(loadStrs.loading, ft_lower, 0, 0, 0, 0).Width); 123 | const titleMeasurements = gr.MeasureString(loadStrs.fileName, ft_lower, 0, 0, 0, 0); 124 | const progressBar = { 125 | x: Math.round(0.025 * ww), 126 | y: Math.round(lowerBarTop + titleMeasurements.Height) + scaleForDisplay(8), 127 | w: Math.round(0.95 * ww), 128 | h: scaleForDisplay(12) + (ww > 1920 ? 2 : 0) 129 | } 130 | gr.DrawString(loadStrs.loading, ft_lower_bold, col.now_playing, progressBar.x, lowerBarTop, progressBar.w, titleMeasurements.Height); 131 | gr.DrawString(loadStrs.fileName, ft_lower, col.now_playing, progressBar.x + loadingWidth + scaleForDisplay(20), lowerBarTop, progressBar.w, titleMeasurements.Height); 132 | gr.FillSolidRect(progressBar.x, progressBar.y, progressBar.w, progressBar.h, col.menu_bg); 133 | gr.FillSolidRect(progressBar.x, progressBar.y, progressBar.w * (loadStrs.fileIndex + 1) / fileList.length, progressBar.h, col.progressFill); 134 | } 135 | -------------------------------------------------------------------------------- /images/icons/original/32/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/32/library.png -------------------------------------------------------------------------------- /images/icons/original/32/lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/32/lyrics.png -------------------------------------------------------------------------------- /images/icons/original/32/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/32/playlist.png -------------------------------------------------------------------------------- /images/icons/original/32/properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/32/properties.png -------------------------------------------------------------------------------- /images/icons/original/32/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/32/settings.png -------------------------------------------------------------------------------- /images/icons/original/32/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/32/star.png -------------------------------------------------------------------------------- /images/icons/original/64/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/64/library.png -------------------------------------------------------------------------------- /images/icons/original/64/lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/64/lyrics.png -------------------------------------------------------------------------------- /images/icons/original/64/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/64/playlist.png -------------------------------------------------------------------------------- /images/icons/original/64/properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/64/properties.png -------------------------------------------------------------------------------- /images/icons/original/64/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/64/settings.png -------------------------------------------------------------------------------- /images/icons/original/64/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/original/64/star.png -------------------------------------------------------------------------------- /images/icons/updated/32/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/32/library.png -------------------------------------------------------------------------------- /images/icons/updated/32/lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/32/lyrics.png -------------------------------------------------------------------------------- /images/icons/updated/32/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/32/playlist.png -------------------------------------------------------------------------------- /images/icons/updated/32/properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/32/properties.png -------------------------------------------------------------------------------- /images/icons/updated/32/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/32/settings.png -------------------------------------------------------------------------------- /images/icons/updated/32/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/32/star.png -------------------------------------------------------------------------------- /images/icons/updated/64/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/64/library.png -------------------------------------------------------------------------------- /images/icons/updated/64/lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/64/lyrics.png -------------------------------------------------------------------------------- /images/icons/updated/64/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/64/playlist.png -------------------------------------------------------------------------------- /images/icons/updated/64/properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/64/properties.png -------------------------------------------------------------------------------- /images/icons/updated/64/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/64/settings.png -------------------------------------------------------------------------------- /images/icons/updated/64/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/icons/updated/64/star.png -------------------------------------------------------------------------------- /images/last-fm-28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/last-fm-28.png -------------------------------------------------------------------------------- /images/last-fm-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/last-fm-36.png -------------------------------------------------------------------------------- /images/last-fm-red-28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/last-fm-red-28.png -------------------------------------------------------------------------------- /images/last-fm-red-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbuffington/Georgia/87adce1e46c4733a7fc5348f615c1058243a3a57/images/last-fm-red-36.png -------------------------------------------------------------------------------- /js/CaTRoX_QWR/Control_Button.js: -------------------------------------------------------------------------------- 1 | 2 | /** @type {Button} */ 3 | let oldButton; 4 | /** @type {Button} */ 5 | let downButton; 6 | let buttonTimer = null; 7 | let mainMenuOpen = false; 8 | 9 | /** @type {Button} */ 10 | let lastOverButton = null; 11 | 12 | /** @type {Button[]} */ 13 | let activatedBtns = []; 14 | 15 | const ButtonState = { 16 | Default: 0, 17 | Hovered: 1, 18 | Down: 2, // happens on click 19 | Enabled: 3, 20 | } 21 | 22 | function buttonEventHandler(x, y, m) { 23 | 24 | // var CtrlKeyPressed = utils.IsKeyPressed(VK_CONTROL); 25 | // var ShiftKeyPressed = utils.IsKeyPressed(VK_SHIFT); 26 | 27 | var c = qwr_utils.caller(); 28 | 29 | /** @type {Button} */ 30 | let thisButton = null; 31 | 32 | for (var i in btns) { 33 | if (typeof btns[i] === 'object' && btns[i].mouseInThis(x, y)) { 34 | thisButton = btns[i]; 35 | break; 36 | } 37 | } 38 | if (lastOverButton != thisButton) { 39 | tt.stop(); 40 | } 41 | lastOverButton = thisButton; 42 | 43 | switch (c) { 44 | 45 | case 'on_mouse_move': 46 | if (downButton) return; 47 | 48 | if (oldButton && oldButton != thisButton) { 49 | oldButton.changeState(oldButton.enabled ? ButtonState.Enabled : ButtonState.Default); 50 | } 51 | if (thisButton && thisButton != oldButton) { 52 | thisButton.changeState(ButtonState.Hovered); 53 | } 54 | 55 | if (lastOverButton) { 56 | if (lastOverButton.tooltip) { 57 | tt.showDelayed(lastOverButton.tooltip); 58 | } else if (lastOverButton.id === 'Volume' && !volume_btn.show_volume_bar) { 59 | tt.showDelayed(fb.Volume.toFixed(2) + ' dB'); 60 | } 61 | } 62 | 63 | oldButton = thisButton; 64 | break; 65 | 66 | case 'on_mouse_lbtn_dblclk': 67 | if (thisButton) { 68 | thisButton.changeState(ButtonState.Down); 69 | downButton = thisButton; 70 | downButton.onDblClick(); 71 | } 72 | break; 73 | 74 | case 'on_mouse_lbtn_down': 75 | if (thisButton) { 76 | thisButton.changeState(ButtonState.Down); 77 | downButton = thisButton; 78 | } 79 | break; 80 | 81 | case 'on_mouse_lbtn_up': 82 | if (downButton) { 83 | downButton.onClick(); 84 | 85 | if (mainMenuOpen) { 86 | thisButton = undefined; 87 | mainMenuOpen = false; 88 | } 89 | if (thisButton) { 90 | thisButton.changeState(thisButton.enabled ? ButtonState.Enabled : ButtonState.Hovered); 91 | } else { 92 | downButton.changeState(downButton.enabled ? ButtonState.Enabled : ButtonState.Default); 93 | } 94 | // thisButton ? thisButton.changeState(ButtonState.Hovered) : downButton.changeState(ButtonState.Default); 95 | 96 | downButton = undefined; 97 | } 98 | break; 99 | 100 | case 'on_mouse_leave': 101 | oldButton = undefined; 102 | if (downButton) return; // for menu buttons 103 | 104 | for (var i in btns) { 105 | if (btns[i].state != 0) { 106 | btns[i].changeState(ButtonState.Default); 107 | } 108 | } 109 | break; 110 | } 111 | return thisButton !== null; 112 | } 113 | 114 | // =================================================== // 115 | const WindowState = { 116 | Normal: 0, 117 | Minimized: 1, 118 | Maximized: 2 119 | } 120 | 121 | class Button { 122 | constructor(x, y, w, h, id, img, tip = undefined, isEnabled = undefined) { 123 | this.x = x; 124 | this.y = y; 125 | this.w = w; 126 | this.h = h; 127 | this.id = id; 128 | this.img = img; 129 | this.tooltip = typeof tip !== 'undefined' ? tip : ''; 130 | this.state = 0; 131 | this.hoverAlpha = 0; 132 | this.downAlpha = 0; 133 | this.isEnabled = isEnabled; // callback 134 | this.enabled = false; 135 | } 136 | 137 | mouseInThis(x, y) { 138 | return (this.x <= x) && (x <= this.x + this.w) && (this.y <= y) && (y <= this.y + this.h); 139 | } 140 | 141 | set enable(val) { 142 | this.enabled = val; 143 | if (!val) { 144 | this.changeState(ButtonState.Default); 145 | } else { 146 | this.changeState(ButtonState.Enabled); 147 | } 148 | } 149 | 150 | repaint() { 151 | window.RepaintRect(this.x, this.y, this.w, this.h); 152 | } 153 | 154 | changeState(state) { 155 | this.state = state; 156 | activatedBtns.push(this); 157 | buttonAlphaTimer(); 158 | } 159 | 160 | onClick() { 161 | btnActionHandler(this); 162 | } 163 | 164 | onDblClick() { 165 | // we don't do anything with dblClick currently 166 | } 167 | } 168 | 169 | /** 170 | * @param {Button} btn 171 | */ 172 | function btnActionHandler(btn) { 173 | switch (btn.id) { 174 | case 'Stop': 175 | fb.Stop(); 176 | break; 177 | case 'Previous': 178 | fb.Prev(); 179 | break; 180 | case 'Play/Pause': 181 | fb.PlayOrPause(); 182 | break; 183 | case 'Next': 184 | fb.Next(); 185 | break; 186 | case 'Playback/Random': 187 | fb.RunMainMenuCommand('Edit/Sort/Randomize'); 188 | if (fb.IsPlaying) { 189 | var playing_location = plman.GetPlayingItemLocation(); 190 | if (playing_location.IsValid) { 191 | var pl = playing_location.PlaylistIndex; 192 | var handles = plman.GetPlaylistItems(pl); 193 | handles.RemoveById(playing_location.PlaylistItemIndex); 194 | plman.ClearPlaylistSelection(pl); 195 | plman.SetPlaylistSelection(pl, [playing_location.PlaylistItemIndex], true); 196 | 197 | plman.RemovePlaylistSelection(pl, true); 198 | plman.InsertPlaylistItems(pl, 1, handles); 199 | plman.EnsurePlaylistItemVisible(pl, 0); 200 | if (displayPlaylist) { 201 | playlist.on_playback_new_track(fb.GetNowPlaying()); // used to scroll item into view 202 | } 203 | } 204 | } else { 205 | var pl = plman.ActivePlaylist; 206 | plman.ClearPlaylistSelection(pl); 207 | plman.SetPlaylistSelection(pl, [0], true); 208 | plman.AddPlaylistItemToPlaybackQueue(pl, 0); 209 | fb.RunMainMenuCommand('Playback/Play'); 210 | } 211 | break; 212 | case 'Volume': 213 | volume_btn.toggleVolumeBar(); 214 | break; 215 | case 'Reload': 216 | window.Reload(); 217 | break; 218 | case 'Console': 219 | fb.RunMainMenuCommand("View/Console"); 220 | break; 221 | case 'Minimize': 222 | fb.RunMainMenuCommand("View/Hide"); 223 | break; 224 | case 'Maximize': 225 | const maximizeToFullScreen = false; // TODO to clear the error. Test this stuff eventually 226 | if (maximizeToFullScreen ? !utils.IsKeyPressed(VK_CONTROL) : utils.IsKeyPressed(VK_CONTROL)) { 227 | UIHacks.FullScreen = !UIHacks.FullScreen; 228 | } else { 229 | if (UIHacks.MainWindowState == WindowState.Maximized) 230 | UIHacks.MainWindowState = WindowState.Normal; 231 | else 232 | UIHacks.MainWindowState = WindowState.Maximized; 233 | } 234 | break; 235 | case 'Close': 236 | fb.Exit(); 237 | break; 238 | case 'File': 239 | case 'Edit': 240 | case 'View': 241 | case 'Playback': 242 | case 'Library': 243 | case 'Help': 244 | onMainMenu(btn.x, btn.y + btn.h, btn.id); 245 | break; 246 | case 'Playlists': 247 | onPlaylistsMenu(btn.x, btn.y + btn.h); 248 | break; 249 | case 'Options': 250 | onOptionsMenu(btn.x, btn.y + btn.h); 251 | break; 252 | case 'Repeat': 253 | var pbo = fb.PlaybackOrder; 254 | if (pbo == PlaybackOrder.Default) { 255 | fb.PlaybackOrder = PlaybackOrder.RepeatPlaylist; 256 | } else if (pbo == PlaybackOrder.RepeatPlaylist) { 257 | fb.PlaybackOrder = PlaybackOrder.RepeatTrack; 258 | } else if (pbo == PlaybackOrder.RepeatTrack) { 259 | fb.PlaybackOrder = PlaybackOrder.Default; 260 | } else { 261 | fb.PlaybackOrder = PlaybackOrder.RepeatPlaylist; 262 | } 263 | break; 264 | case 'Shuffle': 265 | var pbo = fb.PlaybackOrder; 266 | if (pbo != PlaybackOrder.ShuffleTracks) { 267 | fb.PlaybackOrder = PlaybackOrder.ShuffleTracks; 268 | } else { 269 | fb.PlaybackOrder = PlaybackOrder.Default; 270 | } 271 | break; 272 | case 'Mute': 273 | fb.VolumeMute(); 274 | break; 275 | case 'Settings': 276 | fb.ShowPreferences(); 277 | break; 278 | case 'Properties': 279 | fb.RunContextCommand("Properties"); 280 | break; 281 | case 'Rating': 282 | onRatingMenu(btn.x, btn.y + btn.h); 283 | break; 284 | case 'Lyrics': 285 | pref.displayLyrics = !pref.displayLyrics; 286 | btn.enable = pref.displayLyrics; 287 | if ((fb.IsPlaying || fb.IsPaused) && albumart_scaled) { 288 | if (pref.displayLyrics) { 289 | initLyrics(); 290 | } 291 | window.RepaintRect(albumart_size.x, albumart_size.y, albumart_size.w, albumart_size.h); 292 | } 293 | btn.repaint(); 294 | break; 295 | case 'ShowLibrary': 296 | displayLibrary = !displayLibrary; 297 | if (displayLibrary) { 298 | initLibraryPanel(); 299 | setLibrarySize(); 300 | } 301 | if (displayPlaylist) { 302 | displayPlaylist = false; 303 | } else { 304 | ResizeArtwork(false); 305 | } 306 | setupRotationTimer(); // clear or start cdRotation if required 307 | btn.enable = displayLibrary; 308 | btns.playlist.enable = false; 309 | window.Repaint(); 310 | break; 311 | case 'Playlist': 312 | displayPlaylist = !displayPlaylist; 313 | if (displayPlaylist) { 314 | playlist.on_size(ww, wh); 315 | } 316 | if (displayLibrary) { 317 | displayLibrary = false; 318 | } else { 319 | ResizeArtwork(false); 320 | } 321 | setupRotationTimer(); // clear or start cdRotation if required 322 | btn.enable = displayPlaylist; 323 | btns.library.enable = false; 324 | window.Repaint(); 325 | break; 326 | case 'PlaybackTime': 327 | pref.showTimeRemaining = !pref.showTimeRemaining; 328 | on_playback_time(); 329 | break; 330 | case 'Back': 331 | case 'Forward': 332 | if (btn.isEnabled && btn.isEnabled()) { 333 | if (btn.id === 'Back') { 334 | playlistHistory.back(); 335 | } else { 336 | playlistHistory.forward(); 337 | } 338 | } 339 | break; 340 | } 341 | } 342 | 343 | function onPlaylistsMenu(x, y) { 344 | 345 | mainMenuOpen = true; 346 | menu_down = true; 347 | var lists = window.CreatePopupMenu(); 348 | var playlistCount = plman.PlaylistCount; 349 | var playlistId = 3; 350 | lists.AppendMenuItem(MF_STRING, 1, "Playlist manager..."); 351 | lists.AppendMenuSeparator(); 352 | lists.AppendMenuItem(MF_STRING, 2, "Create New Playlist"); 353 | lists.AppendMenuSeparator(); 354 | for (var i = 0; i != playlistCount; i++) { 355 | lists.AppendMenuItem(MF_STRING, playlistId + i, plman.GetPlaylistName(i).replace(/\&/g, '&&') + ' [' + plman.PlaylistItemCount(i) + ']' + (plman.IsAutoPlaylist(i) ? ' (Auto)' : '') + (i === plman.PlayingPlaylist ? ' (Now Playing)' : '')); 356 | } 357 | 358 | var id = lists.TrackPopupMenu(x, y); 359 | 360 | switch (id) { 361 | case 1: 362 | fb.RunMainMenuCommand("View/Playlist Manager"); 363 | break; 364 | case 2: 365 | plman.CreatePlaylist(playlistCount, ""); 366 | plman.ActivePlaylist = plman.PlaylistCount - 1; 367 | break; 368 | } 369 | for (var i = 0; i != playlistCount; i++) { 370 | if (id == (playlistId + i)) plman.ActivePlaylist = i; // playlist switch 371 | } 372 | menu_down = false; 373 | return true; 374 | } 375 | // =================================================== // 376 | 377 | function onMainMenu(x, y, name) { 378 | 379 | mainMenuOpen = true; 380 | menu_down = true; 381 | 382 | if (name) { 383 | var menu = new Menu(name); 384 | 385 | if (name === 'Help') { 386 | var statusMenu = new Menu('Georgia Theme Status'); 387 | 388 | statusMenu.addItem('All fonts installed', fontsInstalled, undefined, true); 389 | statusMenu.addItem('Artist logos found', IsFile(paths.artistlogos + 'Metallica.png'), undefined, true); 390 | statusMenu.addItem('Record label logos found', IsFile(paths.labelsBase + 'Republic.png'), undefined, true); 391 | statusMenu.addItem('Flag images found', IsFile(paths.flagsBase + (is_4k ? '64\\' : '32\\') + 'United-States.png'), undefined, true); 392 | statusMenu.addItem('foo_enhanced_playcount installed', componentEnhancedPlaycount, function() { _.runCmd('https://www.foobar2000.org/components/view/foo_enhanced_playcount') }); 393 | 394 | statusMenu.appendTo(menu); 395 | 396 | menu.addItem('Georgia releases', false, function() { _.runCmd('https://github.com/kbuffington/Georgia/releases') }); 397 | menu.addItem('Georgia changelog', false, function() { _.runCmd('https://github.com/kbuffington/Georgia/blob/master/changelog.md') }); 398 | menu.addItem('Check for updated version of Georgia', false, function() { checkForUpdates(true); }); 399 | menu.addItem('Report an issue with Georgia', false, function() { _.runCmd('https://github.com/kbuffington/Georgia/issues') }); 400 | } 401 | menu.initFoobarMenu(name); 402 | 403 | var ret = menu.trackPopupMenu(x, y); 404 | menu.doCallback(ret); 405 | } 406 | 407 | menu_down = false; 408 | 409 | } 410 | // =================================================== // 411 | 412 | function refreshPlayButton() { 413 | if (transport.enableTransportControls) { 414 | btns.play.img = !fb.IsPlaying || fb.IsPaused ? btnImg.Play : btnImg.Pause; 415 | btns.play.repaint(); 416 | } 417 | } 418 | 419 | // =================================================== // 420 | function buttonAlphaTimer() { 421 | 422 | var trace = false; 423 | 424 | var buttonHoverInStep = 40, 425 | buttonHoverOutStep = 15, 426 | buttonDownInStep = 100, 427 | buttonDownOutStep = 50, 428 | buttonTimerDelay = 25; 429 | 430 | if (!buttonTimer) { 431 | 432 | buttonTimer = setInterval(() => { 433 | 434 | for (var i in activatedBtns) { 435 | switch (activatedBtns[i].state) { 436 | case 0: 437 | activatedBtns[i].hoverAlpha = Math.max(0, activatedBtns[i].hoverAlpha -= buttonHoverOutStep); 438 | activatedBtns[i].downAlpha = Math.max(0, activatedBtns[i].downAlpha -= Math.max(0, buttonDownOutStep)); 439 | activatedBtns[i].repaint(); 440 | break; 441 | case 1: 442 | activatedBtns[i].hoverAlpha = Math.min(255, activatedBtns[i].hoverAlpha += buttonHoverInStep); 443 | activatedBtns[i].downAlpha = Math.max(0, activatedBtns[i].downAlpha -= buttonDownOutStep); 444 | activatedBtns[i].repaint(); 445 | break; 446 | case 2: 447 | activatedBtns[i].downAlpha = Math.min(255, activatedBtns[i].downAlpha += buttonDownInStep); 448 | activatedBtns[i].hoverAlpha = Math.max(0, activatedBtns[i].hoverAlpha -= buttonDownInStep); 449 | activatedBtns[i].repaint(); 450 | break; 451 | } 452 | } 453 | 454 | //---> Test button alpha values and turn button timer off when it's not required; 455 | for (let i = activatedBtns.length - 1; i >= 0; i--) { 456 | if ((!activatedBtns[i].hoverAlpha && !activatedBtns[i].downAlpha) || 457 | activatedBtns[i].hoverAlpha === 255 || activatedBtns[i].downAlpha === 255) { 458 | activatedBtns.splice(i, 1); 459 | } 460 | } 461 | 462 | if (!activatedBtns.length) { 463 | clearInterval(buttonTimer); 464 | buttonTimer = null; 465 | trace && console.log("buttonTimerStarted = false"); 466 | } 467 | 468 | }, buttonTimerDelay); 469 | 470 | trace && console.log("buttonTimerStarted = true"); 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /js/CaTRoX_QWR/Control_ContextMenu.js: -------------------------------------------------------------------------------- 1 | // ==PREPROCESSOR== 2 | // @name 'ContextMenu Control' 3 | // @author 'TheQwertiest' 4 | // ==/PREPROCESSOR== 5 | 6 | g_script_list.push('Control_ContextMenu.js'); 7 | 8 | class ContextBaseObject { 9 | /** 10 | * @param{string} text_arg 11 | */ 12 | constructor(text_arg) { 13 | /** @const {string} */ 14 | this.text = text_arg; 15 | 16 | /** @type {?number} */ 17 | this.idx = undefined; 18 | } 19 | 20 | /** 21 | * @param{number} start_idx 22 | * @return{number} end_idx 23 | * @protected 24 | * @abstract 25 | */ 26 | initialize_menu_idx(start_idx) { 27 | throw new LogicError("initialize_menu_idx not implemented"); 28 | } 29 | 30 | /** 31 | * @param{ContextMenu} parent_menu 32 | * @protected 33 | * @abstract 34 | */ 35 | initialize_menu(parent_menu) { 36 | throw new LogicError("initialize_menu not implemented"); 37 | } 38 | 39 | /** 40 | * @param{number} idx 41 | * @return{boolean} 42 | * @protected 43 | * @abstract 44 | * */ 45 | execute_menu(idx) { 46 | throw new LogicError("execute_menu not implemented"); 47 | } 48 | } 49 | 50 | class ContextMenu extends ContextBaseObject { 51 | 52 | /** 53 | * @param {string} text_arg 54 | * @param {object} [optional_args={}] 55 | * @param {boolean=} [optional_args.is_grayed_out=false] 56 | * @param {boolean=} [optional_args.is_checked=false] 57 | * @constructor 58 | */ 59 | constructor(text_arg, optional_args) { 60 | super(text_arg); 61 | 62 | /** @const {boolean} */ 63 | this.is_grayed_out = !!(optional_args && optional_args.is_grayed_out); 64 | 65 | /** @protected */ 66 | this.menu_items = []; 67 | 68 | this.cm = window.CreatePopupMenu(); 69 | } 70 | 71 | // public: 72 | 73 | /** 74 | * @param{ContextBaseObject} item 75 | */ 76 | append(item) { 77 | if (!(item instanceof ContextBaseObject)) { 78 | throw new InvalidTypeError('context_item', typeof item, 'instanceof ContextBaseObject'); 79 | } 80 | 81 | this.menu_items.push(item); 82 | }; 83 | 84 | /** 85 | * @param {string} text_arg 86 | * @param {function} callback_fn_arg 87 | * @param {object} [optional_args={}] 88 | * @param {boolean=} [optional_args.is_grayed_out=false] 89 | * @param {boolean=} [optional_args.is_checked=false] 90 | * @param {boolean=} [optional_args.is_radio_checked=false] 91 | */ 92 | append_item(text_arg, callback_fn_arg, optional_args) { 93 | this.append(new ContextItem(text_arg, callback_fn_arg, optional_args)); 94 | }; 95 | 96 | append_separator() { 97 | this.append(new ContextSeparator()); 98 | }; 99 | 100 | /** 101 | * @param{number} start_idx 102 | * @param{number} check_idx 103 | */ 104 | radio_check(start_idx, check_idx) { 105 | var item = this.menu_items[start_idx + check_idx]; 106 | if (!item) { 107 | throw new ArgumentError('check_idx', check_idx, 'Value is out of bounds'); 108 | } 109 | 110 | if (start_idx >= this.menu_items.length) { 111 | throw new ArgumentError('start_idx', start_idx, 'Value is out of bounds'); 112 | } 113 | 114 | if (item instanceof ContextSeparator) { 115 | throw new ArgumentError('check_idx', check_idx, 'Index points to MenuSeparator'); 116 | } 117 | 118 | item.radio_check(true) 119 | }; 120 | 121 | /** 122 | * @return {boolean} 123 | */ 124 | is_empty() { 125 | return _.isEmpty(this.menu_items); 126 | }; 127 | 128 | dispose() { 129 | this.cm = null; 130 | 131 | var items = this.menu_items; 132 | for (var i = 0; i < items.length; ++i) { 133 | if (items[i].dispose) { 134 | items[i].dispose(); 135 | } 136 | items[i] = null; 137 | } 138 | 139 | this.menu_items = null; 140 | }; 141 | 142 | /** 143 | * @param{number} start_idx 144 | * @return{number} end_idx 145 | * @protected 146 | */ 147 | initialize_menu_idx(start_idx) { 148 | var cur_idx = start_idx; 149 | 150 | this.idx = cur_idx++; 151 | this.menu_items.forEach(function (item) { 152 | if (!item.initialize_menu_idx) { 153 | return; 154 | } 155 | cur_idx = item.initialize_menu_idx(cur_idx); 156 | }); 157 | 158 | return cur_idx; 159 | }; 160 | 161 | /** 162 | * @param{ContextMenu} parent_menu 163 | * @protected 164 | */ 165 | initialize_menu(parent_menu) { 166 | this.menu_items.forEach(item => { 167 | item.initialize_menu(this); 168 | }); 169 | 170 | this.cm.AppendTo(parent_menu.cm, this.is_grayed_out ? MF_GRAYED : MF_STRING, this.text); 171 | }; 172 | 173 | /** 174 | * @param{number} idx 175 | * @return{boolean} 176 | * @protected 177 | * */ 178 | execute_menu(idx) { 179 | for (var i = 0; i < this.menu_items.length; ++i) { 180 | var items = this.menu_items; 181 | var item = items[i]; 182 | var next_item = items[i + 1]; 183 | 184 | if (idx === item.idx || (idx > item.idx && (!next_item || idx < next_item.idx))) { 185 | return item.execute_menu(idx); 186 | } 187 | } 188 | } 189 | } 190 | 191 | // ContextMenu.prototype = Object.create(ContextBaseObject.prototype); 192 | // ContextMenu.prototype.constructor = ContextMenu; 193 | 194 | 195 | class ContextItem extends ContextBaseObject { 196 | 197 | /** 198 | * @param {string} text_arg 199 | * @param {function} callback_fn_arg 200 | * @param {object} [optional_args={}] 201 | * @param {boolean=} [optional_args.is_grayed_out=false] 202 | * @param {boolean=} [optional_args.is_checked=false] 203 | * @param {boolean=} [optional_args.is_radio_checked=false] 204 | * @constructor 205 | */ 206 | constructor(text_arg, callback_fn_arg, optional_args) { 207 | super(text_arg); 208 | 209 | // const 210 | /** @const {function} */ 211 | this.callback_fn = callback_fn_arg; 212 | 213 | /** @const {boolean} */ 214 | this.is_grayed_out = !!(optional_args && optional_args.is_grayed_out); 215 | 216 | this.is_checked = !!(optional_args && optional_args.is_checked); 217 | this.is_radio_checked = !!(optional_args && optional_args.is_radio_checked); 218 | } 219 | 220 | // public: 221 | 222 | /** 223 | * @param{boolean} is_checked_arg 224 | */ 225 | check(is_checked_arg) { 226 | this.is_checked = is_checked_arg; 227 | } 228 | 229 | /** 230 | * @param{boolean} is_checked_arg 231 | */ 232 | radio_check(is_checked_arg) { 233 | this.is_radio_checked = is_checked_arg; 234 | } 235 | 236 | // protected: 237 | 238 | /** 239 | * @param{number} start_idx 240 | * @return{number} end_idx 241 | * @protected 242 | */ 243 | initialize_menu_idx(start_idx) { 244 | this.idx = start_idx; 245 | return this.idx + 1; 246 | } 247 | 248 | /** 249 | * @param {ContextMenu} parent_menu 250 | * @protected 251 | */ 252 | initialize_menu(parent_menu) { 253 | parent_menu.cm.AppendMenuItem(this.is_grayed_out ? MF_GRAYED : MF_STRING, this.idx, this.text); 254 | if (this.is_checked) { 255 | parent_menu.cm.CheckMenuItem(this.idx, true); 256 | } 257 | else if (this.is_radio_checked) { 258 | parent_menu.cm.CheckMenuRadioItem(this.idx, this.idx, this.idx); 259 | } 260 | } 261 | 262 | /** 263 | * @param{number} idx 264 | * @return{boolean} 265 | * @protected 266 | */ 267 | execute_menu(idx) { 268 | if (this.idx !== idx) { 269 | return false; 270 | } 271 | 272 | this.callback_fn(); 273 | return true; 274 | } 275 | 276 | } 277 | // ContextItem.prototype = Object.create(ContextBaseObject.prototype); 278 | // ContextItem.prototype.constructor = ContextItem; 279 | 280 | /** 281 | * @constructor 282 | * @extends {ContextBaseObject} 283 | */ 284 | class ContextSeparator extends ContextBaseObject { 285 | 286 | constructor () { 287 | super(''); 288 | } 289 | 290 | /** 291 | * @param{number} start_idx 292 | * @return{number} end_idx 293 | * @protected 294 | */ 295 | initialize_menu_idx(start_idx) { 296 | this.idx = start_idx; 297 | return this.idx + 1; 298 | } 299 | 300 | /** 301 | * @param{ContextMenu} parent_menu 302 | * @protected 303 | */ 304 | initialize_menu(parent_menu) { 305 | parent_menu.cm.AppendMenuSeparator(); 306 | } 307 | 308 | /** 309 | * @param{number} idx 310 | * @return{boolean} 311 | * @protected 312 | * */ 313 | execute_menu(idx) { 314 | return false; 315 | } 316 | } 317 | 318 | /** 319 | * @param {FbMetadbHandleList} metadb_handles_arg 320 | * @constructor 321 | * @extends {ContextBaseObject} 322 | */ 323 | class ContextFoobarMenu extends ContextBaseObject { 324 | 325 | constructor (metadb_handles_arg) { 326 | super(''); 327 | 328 | /** @private {IContextMenuManager} */ 329 | this.cm = fb.CreateContextMenuManager(); 330 | 331 | this.metadb_handles = metadb_handles_arg; 332 | 333 | } 334 | 335 | dispose() { 336 | this.cm = null; 337 | } 338 | 339 | /** 340 | * @param {number} start_idx 341 | * @return {number} end_idx 342 | * @protected 343 | */ 344 | initialize_menu_idx(start_idx) { 345 | this.idx = start_idx; 346 | return this.idx + 5000; 347 | } 348 | 349 | /** 350 | * @param {ContextMenu} parent_menu 351 | * @protected 352 | */ 353 | initialize_menu(parent_menu) { 354 | this.cm.InitContext(this.metadb_handles); 355 | this.cm.BuildMenu(parent_menu.cm, this.idx); 356 | } 357 | 358 | /** 359 | * @param {number} idx 360 | * @return {boolean} 361 | * @protected 362 | * */ 363 | execute_menu(idx) { 364 | return this.cm.ExecuteByID(idx - this.idx); 365 | } 366 | } 367 | 368 | class ContextMainMenu extends ContextMenu { 369 | 370 | /** 371 | * @final 372 | * @constructor 373 | */ 374 | constructor() { 375 | super(''); 376 | } 377 | 378 | // public: 379 | 380 | /** @return{boolean} true, if some item was clicked*/ 381 | execute(x, y) { 382 | // Initialize menu 383 | var cur_idx = 1; 384 | this.menu_items.forEach(item => { 385 | if (!item.initialize_menu_idx) { 386 | return; 387 | } 388 | cur_idx = item.initialize_menu_idx(cur_idx); 389 | }); 390 | 391 | this.menu_items.forEach(item => { 392 | item.initialize_menu(this); 393 | }); 394 | 395 | // Execute menu 396 | var idx = this.cm.TrackPopupMenu(x, y); 397 | if (!idx) { 398 | return false; 399 | } 400 | 401 | return this.execute_menu(idx); 402 | } 403 | } 404 | 405 | Object.assign(qwr_utils, { 406 | /** 407 | * @param {ContextMenu} cm 408 | */ 409 | append_default_context_menu_to: function (cm) { 410 | if (!cm) { 411 | return; 412 | } 413 | 414 | if (!cm.is_empty()) { 415 | cm.append_separator(); 416 | } 417 | 418 | cm.append_item( 419 | 'Console', 420 | function () { 421 | fb.ShowConsole(); 422 | }); 423 | 424 | cm.append_item( 425 | 'Restart', 426 | function () { 427 | fb.RunMainMenuCommand("File/Restart"); 428 | }); 429 | 430 | cm.append_item( 431 | 'Preferences...', 432 | function () { 433 | fb.RunMainMenuCommand("File/Preferences"); 434 | }); 435 | 436 | cm.append_separator(); 437 | 438 | var edit = new ContextMenu('Edit panel scripts'); 439 | cm.append(edit); 440 | 441 | var edit_fn = function (script_path) { 442 | if (!_.runCmd("notepad++.exe " + script_path, undefined, true)) { 443 | _.runCmd("notepad.exe " + script_path, undefined, true); 444 | } 445 | }; 446 | 447 | g_script_list.forEach(function (filename) { 448 | var script_path = g_theme.script_folder + filename; 449 | edit.append_item( 450 | filename, 451 | edit_fn.bind(null, script_path), 452 | {is_grayed_out: IsFile(script_path)} 453 | ); 454 | }); 455 | 456 | cm.append_item( 457 | 'Configure panel...', 458 | function () { 459 | window.ShowConfigure(); 460 | }); 461 | 462 | cm.append_item( 463 | 'Panel properties...', 464 | function () { 465 | window.ShowProperties(); 466 | }); 467 | } 468 | }); -------------------------------------------------------------------------------- /js/CaTRoX_QWR/Control_List.js: -------------------------------------------------------------------------------- 1 | // ==PREPROCESSOR== 2 | // @name 'List Control' 3 | // @author 'TheQwertiest' 4 | // ==/PREPROCESSOR== 5 | 6 | g_script_list.push('Control_List.js'); 7 | 8 | g_properties.add_properties( 9 | { 10 | list_left_pad: ['Playlist: padding left', 0], 11 | list_top_pad: ['Playlist: padding top', 0], 12 | list_right_pad: ['Playlist: padding right', 0], 13 | list_bottom_pad: ['Playlist: padding bottom', 15], 14 | 15 | show_scrollbar: ['user.scrollbar.show', true], 16 | scrollbar_right_pad: ['user.scrollbar.pad.right', 0], 17 | scrollbar_top_pad: ['user.scrollbar.pad.top', 0], 18 | scrollbar_bottom_pad: ['user.scrollbar.pad.bottom', 3], 19 | scrollbar_w: ['user.scrollbar.width', utils.GetSystemMetrics(2)], 20 | 21 | row_h: ['user.row.height', 20], 22 | 23 | scroll_pos: ['system.scrollbar.position', 0] 24 | } 25 | ); 26 | 27 | // Fixup properties 28 | (function() { 29 | g_properties.row_h = Math.max(10, g_properties.row_h); 30 | checkFor4k(window.Width, window.Height); 31 | })(); 32 | 33 | /** 34 | * Basic list with a scrollbar. 35 | * By default each item is a row of a fixed size. 36 | * @param {number} x 37 | * @param {number} y 38 | * @param {number} w 39 | * @param {number} h 40 | * @param {ListContent} content Content container 41 | * @constructor 42 | */ 43 | class List { 44 | constructor (x, y, w, h, content) { 45 | // public: 46 | 47 | /** @type {number} */ 48 | this.x = x; 49 | /** @type {number} */ 50 | this.y = y; 51 | /** @type {number} */ 52 | this.w = w; 53 | /** @type {number} */ 54 | this.h = h; 55 | 56 | /** @const {number}*/ 57 | this.row_h = scaleForDisplay(g_properties.row_h); // also see playlist.reinitialize 58 | 59 | /** @type {number} */ 60 | this.background_color = g_theme.colors.pss_back; 61 | /** @type {number} */ 62 | this.panel_back_color = g_theme.colors.panel_back; 63 | 64 | /** @protected {number} */ 65 | this.list_x = this.x + g_properties.list_left_pad; 66 | /** @protected {number} */ 67 | this.list_y = 0; 68 | /** @protected {number} */ 69 | this.list_w = 0; 70 | /** @protected {number} */ 71 | this.list_h = 0; 72 | 73 | /** @protected {number} */ 74 | this.rows_to_draw_precise = 0; 75 | 76 | /** @protected {Array} */ 77 | this.items_to_draw = []; 78 | 79 | // Mouse and key state 80 | 81 | /** @protected {boolean} */ 82 | this.mouse_in = false; 83 | /** @protected {boolean} */ 84 | this.mouse_down = false; 85 | /** @protected {boolean} */ 86 | this.mouse_double_clicked = false; 87 | 88 | // Scrollbar props 89 | 90 | /** 91 | * Row shift is always non-negative 92 | * @protected {number} 93 | */ 94 | this.row_shift = 0; 95 | /** 96 | * Pixel shift is always non-positive 97 | * @protected {number} 98 | */ 99 | this.pixel_shift = 0; 100 | /** @protected {boolean} */ 101 | this.is_scrollbar_visible = g_properties.show_scrollbar; 102 | /** @protected {boolean} */ 103 | this.is_scrollbar_available = false; 104 | 105 | // Objects 106 | 107 | /** @protected {?ScrollBar} */ 108 | this.scrollbar = undefined; 109 | /** @protected {ListContent} */ 110 | this.cnt = content; 111 | 112 | /** 113 | * @private 114 | * @function 115 | */ 116 | this.throttled_repaint = _.throttle(() => { 117 | window.RepaintRect(this.x, this.y, this.w, this.h); 118 | }, 1000 / 60); 119 | } 120 | 121 | on_paint(gr) { 122 | gr.FillSolidRect(this.x, this.y, this.w, this.h, this.background_color); 123 | gr.SetTextRenderingHint(TextRenderingHint.ClearTypeGridFit); 124 | 125 | if (this.items_to_draw.length) { 126 | for(let i = this.items_to_draw.length - 1; i >= 0; --i) { 127 | this.items_to_draw[i].draw(gr); 128 | } 129 | 130 | // Hide items that shouldn't be visible 131 | gr.FillSolidRect(this.x, this.y, this.w, this.list_y - this.y, this.background_color); 132 | gr.FillSolidRect(this.x, this.list_y + this.list_h, this.w, (this.y + this.h) - (this.list_y + this.list_h), this.background_color); 133 | } 134 | else { 135 | var text_format = g_string_format.align_center | g_string_format.trim_ellipsis_char | g_string_format.no_wrap; 136 | gr.DrawString('No rows to display', gdi.Font('Segoe Ui Semibold', 24), RGB(70, 70, 70), this.x, this.y, this.w, this.h, text_format); 137 | } 138 | 139 | if (this.is_scrollbar_available) { 140 | if (!this.scrollbar.is_scrolled_up) { 141 | gr.FillGradRect(this.list_x, this.list_y - 1, this.list_w, 7 + 1, 270, RGBtoRGBA(g_theme.colors.panel_back, 0), RGBtoRGBA(this.panel_back_color, 200)); 142 | } 143 | if (!this.scrollbar.is_scrolled_down) { 144 | gr.FillGradRect(this.list_x, this.list_y + this.list_h - 8, this.list_w, 7 + 1, 90, RGBtoRGBA(g_theme.colors.panel_back, 0), RGBtoRGBA(this.panel_back_color, 200)); 145 | } 146 | } 147 | 148 | if (this.is_scrollbar_visible) { 149 | this.scrollbar.paint(gr); 150 | } 151 | } 152 | 153 | // TODO: Mordred - override this elsewhere 154 | on_size(w, h, x, y) { 155 | var w_changed = this.w !== w || this.x !== x; 156 | var h_changed = this.h !== h || this.y !== y; 157 | 158 | if (h_changed) { 159 | this.y = y; 160 | this.on_h_size(h); 161 | } 162 | 163 | if (w_changed) { 164 | this.x = x; 165 | this.list_x = this.x + g_properties.list_left_pad; 166 | this.on_w_size(w); 167 | } 168 | } 169 | 170 | /** 171 | * @param {number} x 172 | * @param {number} y 173 | * @param {number} m 174 | * @return {boolean} true, if handled 175 | */ 176 | on_mouse_move(x, y, m) { 177 | if (this.is_scrollbar_visible) { 178 | this.scrollbar.move(x, y); 179 | 180 | if (this.scrollbar.b_is_dragging || this.scrollbar.trace(x, y)) { 181 | return true; 182 | } 183 | } 184 | 185 | this.mouse_in = this.trace(x, y); 186 | 187 | return false; 188 | } 189 | 190 | /** 191 | * @param {number} x 192 | * @param {number} y 193 | * @param {number} m 194 | * @return {boolean} true, if handled 195 | */ 196 | on_mouse_lbtn_down(x, y, m) { 197 | this.mouse_down = true; 198 | 199 | if (this.mouse_double_clicked) { 200 | return true; 201 | } 202 | 203 | if (this.is_scrollbar_visible) { 204 | if (this.scrollbar.trace(x, y)) { 205 | this.scrollbar.lbtn_dn(x, y); 206 | return true; 207 | } 208 | } 209 | 210 | return false; 211 | } 212 | 213 | /** 214 | * @param {number} x 215 | * @param {number} y 216 | * @param {number} m 217 | * @return {boolean} true, if handled 218 | */ 219 | on_mouse_lbtn_dblclk(x, y, m) { 220 | this.mouse_down = true; 221 | this.mouse_double_clicked = true; 222 | 223 | if (this.is_scrollbar_visible) { 224 | if (this.scrollbar.trace(x, y)) { 225 | this.scrollbar.lbtn_dn(x, y); 226 | return true; 227 | } 228 | } 229 | 230 | return false; 231 | } 232 | 233 | /** 234 | * @param {number} x 235 | * @param {number} y 236 | * @param {number} m 237 | * @return {boolean} true, if handled 238 | */ 239 | on_mouse_lbtn_up(x, y, m) { 240 | if (!this.mouse_down) { 241 | return true; 242 | } 243 | 244 | this.mouse_double_clicked = false; 245 | this.mouse_down = false; 246 | 247 | if (this.is_scrollbar_visible) { 248 | var wasDragging = this.scrollbar.b_is_dragging; 249 | this.scrollbar.lbtn_up(x, y); 250 | if (wasDragging) { 251 | return true; 252 | } 253 | } 254 | 255 | return false; 256 | } 257 | 258 | /** 259 | * Shows context menu on scrollbar if available. 260 | * Also handles the case when mouse is out of the list. 261 | * 262 | * @param {number} x 263 | * @param {number} y 264 | * @param {number} m 265 | * @return {boolean} true, if handled 266 | */ 267 | on_mouse_rbtn_up(x, y, m) { 268 | if (!this.trace(x,y)) { 269 | return true; 270 | } 271 | 272 | if (!this.is_scrollbar_available 273 | || !this.is_scrollbar_visible 274 | || !this.scrollbar.trace(x,y) ) { 275 | return false; 276 | } 277 | 278 | var cmm = new ContextMainMenu(); 279 | 280 | this.append_scrollbar_visibility_context_menu_to(cmm); 281 | 282 | if (utils.IsKeyPressed(VK_SHIFT)) { 283 | qwr_utils.append_default_context_menu_to(cmm); 284 | } 285 | 286 | menu_down = true; 287 | cmm.execute(x, y); 288 | menu_down = false; 289 | 290 | this.repaint(); 291 | 292 | return true; 293 | } 294 | 295 | on_mouse_wheel(delta) { 296 | if (this.is_scrollbar_available) { 297 | this.scrollbar.wheel(delta); 298 | } 299 | } 300 | 301 | on_mouse_leave() { 302 | if (this.is_scrollbar_available) { 303 | this.scrollbar.leave(); 304 | } 305 | 306 | this.mouse_in = false; 307 | }; 308 | 309 | trace(x, y) { 310 | return x >= this.x && x < this.x + this.w && y >= this.y && y < this.y + this.h; 311 | } 312 | 313 | trace_list(x, y) { 314 | return x >= this.list_x && x < this.list_x + this.list_w && y >= this.list_y && y < this.list_y + this.list_h; 315 | } 316 | 317 | repaint() { 318 | this.throttled_repaint(); 319 | } 320 | 321 | append_scrollbar_visibility_context_menu_to(parent_menu) { 322 | parent_menu.append_item( 323 | 'Show scrollbar', 324 | () => { 325 | g_properties.show_scrollbar = !g_properties.show_scrollbar; 326 | this.on_scrollbar_visibility_change(g_properties.show_scrollbar); 327 | }, 328 | {is_checked: g_properties.show_scrollbar} 329 | ); 330 | } 331 | 332 | /** 333 | * @param {number} h 334 | * @protected 335 | */ 336 | on_h_size(h) { 337 | this.h = h; 338 | this.update_list_h_size(); 339 | }; 340 | 341 | /** 342 | * @param {number} w 343 | * @protected 344 | */ 345 | on_w_size(w) { 346 | this.w = w; 347 | this.update_list_w_size(); 348 | } 349 | 350 | /** 351 | * Called when data in Content is changed. 352 | * @protected 353 | */ 354 | on_list_items_change() { 355 | this.update_scrollbar(); 356 | this.on_content_to_draw_change(); 357 | } 358 | 359 | /** 360 | * @param {number} x 361 | * @param {number} y 362 | * @return {ListItem} 363 | * @protected 364 | */ 365 | get_item_under_mouse(x, y) { 366 | return this.items_to_draw.find(item => item.trace(x, y)); 367 | } 368 | 369 | /** 370 | * @protected 371 | */ 372 | on_content_to_draw_change() { 373 | this.calculate_shift_params(); 374 | this.items_to_draw = 375 | this.cnt.generate_items_to_draw(this.list_y, this.list_h, this.row_shift, this.pixel_shift, this.row_h); 376 | } 377 | 378 | /** 379 | * @protected 380 | */ 381 | scrollbar_redraw_callback() { 382 | g_properties.scroll_pos = this.scrollbar.scroll; 383 | this.on_content_to_draw_change(); 384 | this.repaint(); 385 | } 386 | 387 | /** 388 | * @private 389 | */ 390 | initialize_scrollbar() { 391 | this.is_scrollbar_available = false; 392 | 393 | var scrollbar_x = this.x + this.w - playlist_geo.scrollbar_w - playlist_geo.scrollbar_right_pad; 394 | var scrollbar_y = this.y + playlist_geo.scrollbar_top_pad; 395 | var scrollbar_h = this.h - (playlist_geo.scrollbar_bottom_pad + playlist_geo.scrollbar_top_pad); 396 | 397 | if (this.scrollbar) { 398 | this.scrollbar.reset(); 399 | } 400 | // this.scrollbar = new ScrollBar(scrollbar_x, scrollbar_y, playlist_geo.scrollbar_w, scrollbar_h, this.row_h, _.bind(this.scrollbar_redraw_callback,this)); 401 | this.scrollbar = new ScrollBar(scrollbar_x, scrollbar_y, playlist_geo.scrollbar_w, scrollbar_h, this.row_h, this.scrollbar_redraw_callback.bind(this)); 402 | } 403 | 404 | /** 405 | * @private 406 | */ 407 | update_scrollbar() { 408 | var total_height_in_rows = this.cnt.calculate_total_h_in_rows(); 409 | 410 | if (total_height_in_rows <= this.rows_to_draw_precise) { 411 | this.is_scrollbar_available = false; 412 | g_properties.scroll_pos = 0; 413 | this.on_scrollbar_visibility_change(false); 414 | } 415 | else { 416 | this.scrollbar.set_window_param(this.rows_to_draw_precise, total_height_in_rows); 417 | this.scrollbar.scroll_to(g_properties.scroll_pos, true); 418 | 419 | g_properties.scroll_pos = this.scrollbar.scroll; 420 | this.is_scrollbar_available = true; 421 | this.on_scrollbar_visibility_change(g_properties.show_scrollbar); 422 | } 423 | } 424 | 425 | /** 426 | * @private 427 | */ 428 | on_scrollbar_visibility_change(is_visible) { 429 | if (this.is_scrollbar_visible !== is_visible) { 430 | this.is_scrollbar_visible = is_visible; 431 | this.update_list_w_size(); 432 | } 433 | } 434 | 435 | /** 436 | * @private 437 | */ 438 | update_list_h_size() { 439 | this.list_y = this.y + g_properties.list_top_pad; 440 | this.list_h = this.h - (g_properties.list_bottom_pad + g_properties.list_top_pad); 441 | 442 | this.rows_to_draw_precise = this.list_h / this.row_h; 443 | 444 | this.initialize_scrollbar(); 445 | this.update_scrollbar(); 446 | this.on_content_to_draw_change(); 447 | } 448 | 449 | /** 450 | * @private 451 | */ 452 | update_list_w_size() { 453 | this.list_w = this.w - g_properties.list_left_pad - g_properties.list_right_pad; 454 | 455 | if (this.is_scrollbar_available) { 456 | if (this.is_scrollbar_visible) { 457 | this.list_w -= this.scrollbar.w + 2; 458 | } 459 | this.scrollbar.set_x(this.w - playlist_geo.scrollbar_w - playlist_geo.scrollbar_right_pad); 460 | } 461 | 462 | // TODO: Mordred - override this elsewhere 463 | this.initialize_scrollbar(); 464 | this.update_scrollbar(); 465 | this.on_content_to_draw_change(); 466 | 467 | this.cnt.update_items_w_size(this.list_w); 468 | } 469 | 470 | /** 471 | * @private 472 | */ 473 | calculate_shift_params() { 474 | this.row_shift = Math.floor(g_properties.scroll_pos); 475 | this.pixel_shift = -Math.round((g_properties.scroll_pos - this.row_shift) * this.row_h); 476 | } 477 | } 478 | 479 | class ListItem { 480 | /** 481 | * @param {number} x 482 | * @param {number} y 483 | * @param {number} w 484 | * @param {number} h 485 | * @constructor 486 | */ 487 | constructor(x, y, w, h) { 488 | /** 489 | * @private 490 | * @function 491 | */ 492 | this.throttled_repaint = _.throttle(() => { 493 | window.RepaintRect(this.x, this.y, this.w, this.h); 494 | }, 1000 / 60); 495 | 496 | this.x = x; 497 | this.y = y; 498 | this.w = w; 499 | this.h = h; 500 | } 501 | 502 | /** 503 | * @param {GdiGraphics} gr 504 | * @abstract 505 | */ 506 | draw(gr) { 507 | throw new LogicError("draw not implemented"); 508 | } 509 | repaint() { 510 | this.throttled_repaint(); 511 | } 512 | /** 513 | * @param {number} x 514 | * @param {number} y 515 | * @return {boolean} 516 | */ 517 | trace(x, y) { 518 | return x >= this.x && x < this.x + this.w && y >= this.y && y < this.y + this.h; 519 | } 520 | 521 | /** 522 | * @param {number} y 523 | */ 524 | set_y(y) { 525 | this.y = y; 526 | } 527 | /** 528 | * @param {number} w 529 | */ 530 | set_w(w) { 531 | this.w = w; 532 | } 533 | } 534 | 535 | 536 | 537 | /** 538 | * Content container 539 | * @constructor 540 | */ 541 | class ListContent { 542 | constructor () {}; 543 | 544 | /** 545 | * Generates item list to draw 546 | * Called in three cases: 547 | * 1. Window vertical size changed 548 | * 2. Scroll position changed 549 | * 3. List cnt changed 550 | * @param {number} wy List Y coordinate 551 | * @param {number} wh List height 552 | * @param {number} row_shift List shift in rows (shift_in_pixels/row_h) 553 | * @param {number} pixel_shift List shift in pixels (shift_in_pixels - row_shift) 554 | * @param {number} row_h Row height 555 | * @return {Array} 556 | * @abstract 557 | */ 558 | generate_items_to_draw(wy, wh, row_shift, pixel_shift, row_h) { 559 | throw new LogicError("generate_items_to_draw not implemented"); 560 | }; 561 | 562 | /** 563 | * Sets new width of the items 564 | * @param {number} w 565 | * @abstract 566 | */ 567 | update_items_w_size(w) { 568 | throw new LogicError("update_items_w_size not implemented"); 569 | }; 570 | 571 | /** 572 | * @return {number} Total cnt height in rows, i.e. total_h/row_h 573 | * @abstract 574 | */ 575 | calculate_total_h_in_rows() { 576 | throw new LogicError("calculate_total_h_in_rows not implemented"); 577 | }; 578 | } 579 | 580 | /** 581 | * Basic cnt container, which may contain only Items with height of row_h 582 | * @constructor 583 | * @extend {ListContent} 584 | */ 585 | class ListRowContent extends ListContent { 586 | constructor() { 587 | super(); 588 | 589 | /** @type {Array} */ 590 | this.rows = []; 591 | } 592 | 593 | generate_items_to_draw(wy, wh, row_shift, pixel_shift, row_h) { 594 | if (!this.rows.length) { 595 | return []; 596 | } 597 | 598 | var items_to_draw = []; 599 | var cur_y = wy + pixel_shift; 600 | 601 | for (var i = row_shift; i < this.rows.length; ++i) { 602 | this.rows[i].y = cur_y; 603 | items_to_draw.push(this.rows[i]); 604 | cur_y += row_h; 605 | 606 | if (cur_y >= wy + wh) { 607 | break; 608 | } 609 | } 610 | 611 | return items_to_draw; 612 | } 613 | 614 | update_items_w_size(w) { 615 | this.rows.forEach(item => { 616 | item.set_w(w); 617 | }); 618 | } 619 | 620 | calculate_total_h_in_rows() { 621 | return this.rows.length; 622 | } 623 | } 624 | -------------------------------------------------------------------------------- /js/CaTRoX_QWR/Utility_LinkedList.js: -------------------------------------------------------------------------------- 1 | // ==PREPROCESSOR== 2 | // @name 'Linked List' 3 | // @author 'TheQwertiest' 4 | // ==/PREPROCESSOR== 5 | 6 | /** 7 | * @template T 8 | */ 9 | class Node { 10 | /** 11 | * @param {T} value 12 | * @param {?Node} prev 13 | * @param {?Node} next 14 | * @constructor 15 | * @struct 16 | */ 17 | constructor(value, prev, next) { 18 | this.value = value; 19 | this.prev = prev; 20 | this.next = next; 21 | } 22 | } 23 | 24 | /** 25 | * @constructor 26 | * @template T 27 | */ 28 | function LinkedList() { 29 | this.clear = function () { 30 | back = null; 31 | front = null; 32 | size = 0; 33 | }; 34 | 35 | /** 36 | * @param {T} value 37 | */ 38 | this.push_back = function (value) { 39 | add_node(new Node(value, back, null)); 40 | }; 41 | 42 | /** 43 | * @param {T} value 44 | */ 45 | this.push_front = function (value) { 46 | add_node(new Node(value, null, front)); 47 | }; 48 | 49 | this.pop_front = function () { 50 | remove_node(front); 51 | }; 52 | 53 | this.pop_back = function () { 54 | remove_node(back); 55 | }; 56 | 57 | /** 58 | * @param {LinkedList.Iterator} iterator 59 | */ 60 | this.remove = function (iterator) { 61 | if (!(iterator instanceof LinkedList.Iterator)) { 62 | throw new InvalidTypeError(iterator, typeof iterator, 'Iterator'); 63 | } 64 | 65 | if (iterator.parent !== this) { 66 | throw new LogicError('Using iterator from a different list'); 67 | } 68 | 69 | if (iterator.compare(this.end())) { 70 | throw new LogicError('Removing invalid iterator'); 71 | } 72 | 73 | remove_node(iterator.cur_node); 74 | 75 | iterator.cur_node = this.end_node; 76 | }; 77 | 78 | /** 79 | * @return {T} 80 | */ 81 | this.front = function () { 82 | return front.value; 83 | }; 84 | 85 | /** 86 | * @return {T} 87 | */ 88 | this.back = function () { 89 | return back.value; 90 | }; 91 | 92 | /** 93 | * @return {number} 94 | */ 95 | this.length = function () { 96 | return size; 97 | }; 98 | 99 | /** 100 | * This method creates Iterator object 101 | * @return {LinkedList.Iterator} 102 | */ 103 | this.begin = function () { 104 | return new LinkedList.Iterator(this, front ? front : this.end_node); 105 | }; 106 | 107 | /** 108 | * This method creates Iterator object 109 | * @return {LinkedList.Iterator} 110 | */ 111 | this.end = function () { 112 | return new LinkedList.Iterator(this, this.end_node); 113 | }; 114 | 115 | /** 116 | * @param {Node} node 117 | */ 118 | function add_node(node) { 119 | if (node.prev) { 120 | node.prev.next = node; 121 | } 122 | else { 123 | front = node; 124 | } 125 | 126 | if (node.next) { 127 | node.next.prev = node; 128 | } 129 | else { 130 | back = node; 131 | } 132 | 133 | ++size; 134 | } 135 | 136 | /** 137 | * @param {?Node} node 138 | */ 139 | function remove_node(node) { 140 | if (!node) { 141 | return; 142 | } 143 | 144 | if (node.prev) { 145 | node.prev.next = node.next; 146 | } 147 | else { 148 | front = node.next; 149 | } 150 | 151 | if (node.next) { 152 | node.next.prev = node.prev; 153 | } 154 | else { 155 | back = node.prev; 156 | } 157 | 158 | --size; 159 | } 160 | 161 | /** @type {?Node} */ 162 | var back = null; 163 | /** @type {?Node} */ 164 | var front = null; 165 | /** @type {number} */ 166 | var size = 0; 167 | 168 | /** 169 | * @const {Node} 170 | */ 171 | this.end_node = new Node(null, null, null); 172 | } 173 | 174 | /** 175 | * @param {LinkedList} parent 176 | * @param {Node} node 177 | * @constructor 178 | * @template T 179 | */ 180 | LinkedList.Iterator = function (parent, node) { 181 | this.increment = function () { 182 | if (this.cur_node === parent.end_node) { 183 | throw new LogicError('Iterator is out of bounds'); 184 | } 185 | 186 | // @ts-ignore 187 | this.cur_node = this.cur_node.next; 188 | if (!this.cur_node) { 189 | this.cur_node = parent.end_node; 190 | } 191 | }; 192 | 193 | this.decrement = function () { 194 | // @ts-ignore 195 | if (this.cur_node === front) { 196 | throw new LogicError('Iterator is out of bounds'); 197 | } 198 | 199 | if (this.cur_node === parent.end_node) { 200 | // @ts-ignore 201 | this.cur_node = back; 202 | } else { 203 | // @ts-ignore 204 | this.cur_node = this.cur_node.prev; 205 | } 206 | }; 207 | 208 | /** 209 | * @return {T} 210 | */ 211 | this.value = function () { 212 | if (this.cur_node === parent.end_node) { 213 | throw new LogicError('Accessing end node'); 214 | } 215 | 216 | return this.cur_node.value; 217 | }; 218 | 219 | /** 220 | * @param {LinkedList.Iterator} iterator 221 | * @return {boolean} 222 | */ 223 | this.compare = function (iterator) { 224 | if (iterator.parent !== this.parent) { 225 | throw new LogicError('Comparing iterators from different lists'); 226 | } 227 | return iterator.cur_node === this.cur_node; 228 | }; 229 | 230 | /** @const {LinkedList} */ 231 | this.parent = parent; 232 | /** @type {Node} */ 233 | this.cur_node = node; 234 | }; 235 | -------------------------------------------------------------------------------- /js/CaTRoX_QWR/html/GroupPresetsMngr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 32 | Foobar2000: Manage grouping presets 33 | 34 | 35 |
36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | 46 | 47 |
48 |
49 |
50 |
51 | 52 | 53 |
54 |
55 | 56 | 57 |
58 |
59 | 60 | 61 |
62 |
63 | 64 | 65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 |
75 | 76 | 77 |
78 |
79 |
80 | 81 | 82 | 83 | 434 | 435 | -------------------------------------------------------------------------------- /js/CaTRoX_QWR/html/MsgBox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | Placeholder title 15 | 16 | 17 |
18 | 19 | 20 |
21 | 113 | 114 | -------------------------------------------------------------------------------- /js/CaTRoX_QWR/html/PopupWithCheckBox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 17 | Placeholder title 18 | 19 | 20 |
Placeholder main text
21 |
22 | 23 | 24 |
25 | 26 | 76 | 77 | -------------------------------------------------------------------------------- /js/CaTRoX_QWR/html/styles10.css: -------------------------------------------------------------------------------- 1 | body { color: WindowText; background-color: Menu; } 2 | input { font:caption; border: 1px solid #7A7A7A; width: 100%; } 3 | input:focus { outline: none !important; border:1px solid #0078D7; } 4 | input:hover:focus { outline: none !important; border:1px solid #0078D7; } 5 | input:hover { outline: none !important; border:1px solid #000000; } 6 | label { font:caption; } 7 | button { font:caption; background: #E1E1E1; color:ButtonText; border: 1px solid #ADADAD; margin: 5px; padding: 3px; width: 70px; } 8 | button:focus { outline: none !important; border:2px solid #0078D7; padding: 2px; } 9 | button:hover { background: #e5f1fb; outline: none !important; border:1px solid #0078D7; padding: 3px; }div { overflow: hidden; } 10 | button:focus:hover { background: #e5f1fb; outline: none !important; border:2px solid #0078D7; padding: 2px; } 11 | span { display:block; overflow: hidden; padding-right:10px; } 12 | button[disabled] { background: #CCCCCC; color:#EBEBE4; } 13 | /* --Suppress button:hover manually, since not() is not working =( */ 14 | button[disabled]:hover { border: 1px solid #ADADAD; padding: 3px; } -------------------------------------------------------------------------------- /js/CaTRoX_QWR/html/styles7.css: -------------------------------------------------------------------------------- 1 | body { color: WindowText; background-color: Menu; } 2 | input { font:caption; border: 1px solid #7A7A7A; width: 100%; } 3 | input:focus { outline: none !important; border:1px solid #0078D7; } 4 | input:hover:focus { outline: none !important; border:1px solid #0078D7; } 5 | input:hover { outline: none !important; border:1px solid #000000; } 6 | label { font:caption; } 7 | button { font:caption; background: #E1E1E1; color:ButtonText; border: 1px solid #ADADAD; margin: 5px; padding: 3px; width: 70px; } 8 | button:focus { border: 1px solid #0078D7; padding: 3px; } 9 | button:hover { background: #e5f1fb; border: 1px solid #0078D7; padding: 3px; } 10 | button:focus:hover { background: #e5f1fb; border:1px solid #0078D7; padding: 3px; } 11 | button[disabled] { background: #CCCCCC; color:#EBEBE4; } 12 | /* --Suppress button:hover manually, since not() is not working =( */ 13 | button[disabled]:hover { border: 1px solid #ADADAD; padding: 3px; } -------------------------------------------------------------------------------- /js/configuration.js: -------------------------------------------------------------------------------- 1 | const ConfigurationObjectType = { 2 | Array: 'array', 3 | Object: 'object', 4 | Value: 'value' // not currently handled 5 | }; 6 | 7 | /** 8 | * @typedef {Object} FieldDefinition 9 | * @property {string} name 10 | * @property {boolean=} optional 11 | */ 12 | 13 | class ConfigurationObjectSchema { 14 | /** 15 | * @param {string} name The name to be used for the object in the configuration file. i.e. if the object is `grid: {}`, then name should be `'grid'` 16 | * @param {string} container The type of container for the object. Should be of ConfigurationObjectType. 17 | * @param {Array=} fields The fields for each entry in the object. If undefined, uses key/value pairs for objects, or comma separated values for arrays. 18 | * @param {string=} comment Adds a '//' field as first entry in the object. Used for explaining things to the user. 19 | */ 20 | constructor(name, container, fields = undefined, comment = undefined) { 21 | this.name = name; 22 | this.container = container; 23 | this.fields = fields; 24 | this.comment = comment; 25 | } 26 | } 27 | 28 | /** 29 | * @typedef {Object} ConfigurationObject 30 | * @property {ConfigurationObjectSchema} definition 31 | * @property {Array} values 32 | * @property {Array} comments 33 | */ 34 | 35 | /** 36 | * Read/write theme configuration to a JSON file 37 | */ 38 | class Configuration { 39 | /** 40 | * Instantiate Configuration object and specify file to read from 41 | * @param {string} configurationPath Path to the config file 42 | */ 43 | constructor(configurationPath) { 44 | this.path = configurationPath; 45 | if (!configurationPath.includes('.jsonc')) { 46 | console.log('') 47 | } 48 | 49 | /** 50 | * @protected 51 | * @type {Array} 52 | */ 53 | this._configuration = []; 54 | } 55 | 56 | /** @returns {boolean} */ 57 | get fileExists() { 58 | return IsFile(this.path); 59 | } 60 | 61 | /** 62 | * 63 | * @param {ConfigurationObjectSchema} objectDefinition 64 | * @param {*} values 65 | * @param {*} comments 66 | * @returns {ThemeSettings} Provides getters and setters to automatically update config file when config val changes 67 | */ 68 | addConfigurationObject(objectDefinition, values, comments = []) { 69 | /** @type {ConfigurationObject} */ 70 | const obj = { definition: objectDefinition, values, comments }; 71 | const idx = this._configuration.findIndex(c => c.definition.name === objectDefinition.name); 72 | if (idx !== -1) { 73 | // replace existing object 74 | this._configuration.splice(idx, 1, obj); 75 | } else { 76 | this._configuration.push(obj); 77 | } 78 | return this.getConfigObject(objectDefinition.name); 79 | } 80 | 81 | /** 82 | * 83 | * @param {String} name 84 | * @returns {ThemeSettings} 85 | */ 86 | getConfigObject(name) { 87 | const obj = this._configuration.find(c => c.definition.name === name); 88 | return new ThemeSettings(this, name, obj); 89 | } 90 | 91 | /** 92 | * Replace the stored values for the object 93 | * @param {String} objectName The name to be used for the object in the configuration file. i.e. if the object is `grid: {}`, then objectName should be `'grid'` 94 | * @param {*} values 95 | * @param {boolean} writeConfig 96 | */ 97 | updateConfigObjValues(objectName, values, writeConfig = false) { 98 | const configObj = this._configuration.find(c => c.definition.name === objectName); 99 | Object.assign(configObj.values, values); 100 | if (writeConfig) { 101 | this.writeConfiguration(); 102 | } 103 | } 104 | 105 | /** 106 | * @returns {Object} An object containing 107 | */ 108 | readConfiguration() { 109 | try { 110 | const f = fso.GetFile(this.path); 111 | const p = f.OpenAsTextStream(FileMode.Read, FileType.Unicode); 112 | const jsonString = stripJsonComments(p.ReadAll()); 113 | const config = JSON.parse(jsonString); 114 | p.Close(); 115 | return config; 116 | } 117 | catch (e) { 118 | throw new ThemeError(``) 120 | } 121 | } 122 | 123 | /** 124 | * Writes the configuration file to the path specified when Configuration was instantiated. Only needs 125 | * to be called manually the very first time, or if not calling updateConfigObjValues (only happens if 126 | * not using a ThemeSettings object received from addConfigurationObject. 127 | */ 128 | writeConfiguration() { 129 | const p = fso.CreateTextFile(this.path, true, true); 130 | p.WriteLine('/* Configuration file for Georgia. Manual changes to this file will take effect'); 131 | p.WriteLine(' on the next reload. To ensure changes are not overwritten or lost, reload theme'); 132 | p.WriteLine(' immediately after manually changing values. */'); 133 | p.WriteLine('{'); 134 | p.WriteLine(`\t"configVersion": "${currentVersion}", // used to update saved configs. You probably shouldn't manually edit this.`) 135 | this._configuration.forEach((conf, i) => { 136 | const container = conf.definition.container === ConfigurationObjectType.Array ? '[' : '{'; 137 | p.WriteLine(`\t"${conf.definition.name}": ${container}`); 138 | if (conf.definition.comment) { 139 | let line = conf.definition.comment; 140 | let done = false; 141 | while (!done) { 142 | const lineLen = 100; 143 | if (line.length < lineLen) { 144 | p.WriteLine(`\t\t// ${line.trim()}`); 145 | done = true; 146 | } else { 147 | const idx = line.lastIndexOf(' ', lineLen); 148 | p.WriteLine(`\t\t// ${line.substr(0, idx).trim()}`); 149 | line = line.substr(idx); 150 | } 151 | } 152 | } 153 | if (conf.definition.fields) { 154 | // array of fields 155 | for (let i=0; i < conf.values.length; i++) { 156 | let entry = ''; 157 | if (conf.definition.container === ConfigurationObjectType.Array) { 158 | conf.definition.fields.forEach(field => { 159 | if (!field.optional || conf.values[i][field.name]) { 160 | const quotes = typeof conf.values[i][field.name] === 'string' ? '"': ''; 161 | entry += `, "${field.name}": ${quotes}${conf.values[i][field.name]}${quotes}`; 162 | } 163 | }); 164 | entry = `{${entry.substr(1)} }`; 165 | } 166 | const comment = conf.values[i].comment ? ' // ' + conf.values[i].comment : ''; 167 | p.WriteLine(`\t\t${entry}${i < conf.values.length - 1 ? ',' : ''}${comment}`); 168 | } 169 | } else { 170 | if (conf.definition.container === ConfigurationObjectType.Array) { 171 | // array of comma separated entries 172 | conf.values.forEach((val, i) => { 173 | p.WriteLine(`\t\t"${val.replace(/\\/g, '\\\\')}"${i < conf.values.length - 1 ? ',' : ''}`); 174 | }) 175 | } else { 176 | // object with key/value pairs 177 | const keys = Object.keys(conf.values); 178 | keys.forEach((key, i) => { 179 | const comment = conf.comments[key] ? ` // ${conf.comments[key]}` : ''; 180 | const quotes = typeof conf.values[key] === 'string' ? '"': ''; 181 | p.WriteLine(`\t\t"${key}": ${quotes}${conf.values[key]}${quotes}${i < keys.length - 1 ? ',' : ''}${comment}`) 182 | }); 183 | } 184 | } 185 | const closeContainer = conf.definition.container === ConfigurationObjectType.Array ? ']' : '}'; 186 | p.WriteLine(`\t${closeContainer}${i < this._configuration.length - 1 ? ',' : ''}`); 187 | }); 188 | p.WriteLine('}'); 189 | p.Close(); 190 | } 191 | 192 | getPath() { 193 | return this.path; 194 | } 195 | 196 | resetConfiguration() { 197 | fso.DeleteFile(this.path); 198 | setTimeout(() => { 199 | window.Reload(); 200 | }, 1); 201 | } 202 | } 203 | 204 | const singleComment = Symbol('singleComment'); 205 | const multiComment = Symbol('multiComment'); 206 | const stripWithoutWhitespace = () => ''; 207 | const stripWithWhitespace = (string, start, end) => string.slice(start, end).replace(/\S/g, ' '); 208 | 209 | const isEscaped = (jsonString, quotePosition) => { 210 | let index = quotePosition - 1; 211 | let backslashCount = 0; 212 | 213 | while (jsonString[index] === '\\') { 214 | index -= 1; 215 | backslashCount += 1; 216 | } 217 | 218 | return Boolean(backslashCount % 2); 219 | }; 220 | 221 | // https://github.com/sindresorhus/strip-json-comments/blob/master/index.js 222 | function stripJsonComments(jsonString, options = { whitespace: false }) { 223 | if (typeof jsonString !== 'string') { 224 | throw new TypeError(`Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``); 225 | } 226 | 227 | const strip = options.whitespace === false ? stripWithoutWhitespace : stripWithWhitespace; 228 | 229 | let insideString = undefined; 230 | let insideComment = undefined; 231 | let offset = 0; 232 | let result = ''; 233 | 234 | for (let i = 0; i < jsonString.length; i++) { 235 | const currentCharacter = jsonString[i]; 236 | const nextCharacter = jsonString[i + 1]; 237 | 238 | if (!insideComment && currentCharacter === '"') { 239 | const escaped = isEscaped(jsonString, i); 240 | if (!escaped) { 241 | insideString = !insideString; 242 | } 243 | } 244 | 245 | if (insideString) { 246 | continue; 247 | } 248 | 249 | if (!insideComment && currentCharacter + nextCharacter === '//') { 250 | result += jsonString.slice(offset, i); 251 | offset = i; 252 | insideComment = singleComment; 253 | i++; 254 | } else if (insideComment === singleComment && currentCharacter + nextCharacter === '\r\n') { 255 | i++; 256 | insideComment = false; 257 | result += strip(jsonString, offset, i); 258 | offset = i; 259 | continue; 260 | } else if (insideComment === singleComment && currentCharacter === '\n') { 261 | insideComment = false; 262 | result += strip(jsonString, offset, i); 263 | offset = i; 264 | } else if (!insideComment && currentCharacter + nextCharacter === '/*') { 265 | result += jsonString.slice(offset, i); 266 | offset = i; 267 | insideComment = multiComment; 268 | i++; 269 | continue; 270 | } else if (insideComment === multiComment && currentCharacter + nextCharacter === '*/') { 271 | i++; 272 | insideComment = false; 273 | result += strip(jsonString, offset, i + 1); 274 | offset = i + 1; 275 | continue; 276 | } 277 | } 278 | 279 | return result + (insideComment ? strip(jsonString.slice(offset)) : jsonString.slice(offset)); 280 | }; 281 | 282 | 283 | /** 284 | * @param {string} name 285 | * @param {*} settingVal 286 | * @constructor 287 | */ 288 | class ThemeSetting { 289 | constructor(name, settingVal) { 290 | /** @const {string} */ 291 | this.name = name; 292 | this.value = settingVal; 293 | } 294 | 295 | /** 296 | * @return {*} 297 | */ 298 | get() { 299 | return this.value; 300 | } 301 | 302 | /** 303 | * @param {*} new_value 304 | */ 305 | set(new_value) { 306 | if (this.value !== new_value) { 307 | this.value = new_value; 308 | } 309 | }; 310 | } 311 | 312 | class ThemeSettings { 313 | /** 314 | * 315 | * @param {Configuration} config 316 | * @param {String} objName 317 | * @param {ConfigurationObject} properties 318 | */ 319 | constructor(config, objName, properties = undefined) { 320 | /** @protected */ 321 | this._properties_name_list = []; 322 | /** @protected */ 323 | /** @type {Configuration} */ 324 | this._config = config; 325 | this.objName = objName; 326 | if (properties) { 327 | this.add_properties(properties.values); 328 | } 329 | } 330 | 331 | /** 332 | * @param {ConfigurationObject|*} properties Each item in array is an array of objects } 333 | */ 334 | add_properties(properties) { 335 | Object.keys(properties).forEach(key => { 336 | this.validate_config_item(properties[key], key); 337 | this.add_config_item(properties[key], key); 338 | }) 339 | }; 340 | 341 | /** 342 | * TODO: validation for item? 343 | * @param {*} item 344 | * @param {String} item_id 345 | */ 346 | validate_config_item(item, item_id) { 347 | // if (!_.isArray(item) || item.length !== 2 || !_.isString(item[0])) { 348 | // throw new InvalidTypeError('property', typeof item, '{ string, [string, any] }', 'Usage: add_properties({\n property_id: [property_name, property_default_value]\n})'); 349 | // } 350 | if (item_id === 'add_properties') { 351 | throw new ArgumentError('property_id', item_id, 'This id is reserved'); 352 | } 353 | if (this[item_id] || this[item_id + '_internal']) { 354 | throw new ArgumentError('property_id', item_id, 'This id is already occupied'); 355 | } 356 | } 357 | 358 | add_config_item(setting, item_id) { 359 | this._properties_name_list[setting[0]] = 1; 360 | 361 | this[item_id + '_internal'] = new ThemeSetting(item_id, setting); 362 | 363 | Object.defineProperty(this, item_id, { 364 | get: function () { 365 | return this[item_id + '_internal'].get(); 366 | }, 367 | set: function (new_value) { 368 | if (this[item_id + '_internal'].get() !== new_value) { 369 | this[item_id + '_internal'].set(new_value); 370 | this._config.updateConfigObjValues(this.objName, { [item_id]: new_value }, true); 371 | } 372 | } 373 | }); 374 | } 375 | } -------------------------------------------------------------------------------- /js/defaults.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the various definitions, default values, and schemeas for the 3 | * objects that will be written to the configuration file. Any value defined here 4 | * will be written to the config (although some of these objects will be modified 5 | * in settings.js with values that are not saved). 6 | * DO NOT EDIT: Editing these values will likely not provide you with the results 7 | * you expect as they will probably not be stored in the configs. 8 | */ 9 | 10 | /** @type {*} Title formatting strings used throughout the UI */ 11 | let tf = {}; // defining each entry separately for auto-complete purposes 12 | tf.album_subtitle = '%albumsubtitle%'; 13 | tf.album_translation = '%albumtranslation%'; 14 | tf.artist_country = '%artistcountry%'; 15 | tf.artist = '$if3($meta(artist),%composer%,%performer%,%album artist%)'; 16 | tf.date = '$if3(%original release date%,%originaldate%,%date%,%fy_upload_date%,)'; 17 | tf.disc_subtitle = '%discsubtitle%'; 18 | tf.disc = '$ifgreater(%totaldiscs%,1,CD %discnumber%/%totaldiscs%,)'; 19 | tf.edition = '[$if(%original release date%,$ifequal($year(%original release date%),$year(%date%),,$year(%date%) ))$if2(%edition%,\'release\')]'; 20 | tf.last_played = '[$if2(%last_played_enhanced%,%last_played%)]'; 21 | tf.lyrics = '[$if3(%synced lyrics%,%syncedlyrics%,%lyrics%,%lyric%,%unsyncedlyrics%,%unsynced lyrics%,)]'; 22 | tf.original_artist = '[ \'(\'%original artist%\' cover)\']'; 23 | tf.releaseCountry = '$replace($if3(%releasecountry%,%discogs_country%,),AF,XW)'; 24 | tf.title = '%title%[ \'[\'%translation%\']\']'; 25 | tf.tracknum = '[%tracknumber%.]'; 26 | tf.vinyl_side = '%vinyl side%'; 27 | tf.vinyl_tracknum = '%vinyl tracknumber%'; 28 | tf.year = '[$year($if3(%original release date%,%originaldate%,%date%,%fy_upload_date%,))]'; 29 | const defaultTitleFormatStrings = _.cloneDeep(tf); 30 | 31 | const titleFormatComments = { 32 | artist_country: 'Only used for displaying artist flags.', 33 | date: 'The full date stored for the track', 34 | lyrics: 'Lyrics.js will check these fields in order if no local lyrics file is found.', 35 | releaseCountry: 'Releases tagged from Musicbrainz with a release country of AF (Afghanistan) are almost always whole world releases that have each country listed individually, so replace with \'XW\' (Worldwide) tag.', 36 | title: 'Track title shown above the progress bar', 37 | vinyl_side: 'Used for determining what side a song appears on for vinyl releases - i.e. song A1 has a %vinyl side% of "A"', 38 | vinyl_tracknum: 'Used for determining the track number on vinyl releases - i.e. song A1 has %vinyl tracknumber% set to "1"', 39 | year: 'Just the year portion of any stored date.', 40 | } 41 | const titleFormatSchema = new ConfigurationObjectSchema('title_format_strings', ConfigurationObjectType.Object, undefined, 42 | 'Title formatting strings, used throughout the display. Do NOT change the key names or add new ones.'); 43 | 44 | /** 45 | * @typedef {Object} MetadataGridEntry 46 | * @property {string} label Text that shows in the left column of the metadata grid 47 | * @property {string} val Evaluated text in the right column. If this evaluates to an empty string, the entry is not shown. 48 | * @property {boolean=} age If True, appends the "(1y 10, 23d)" style text to the evaluated val. Only valid for date strings 49 | * @property {string=} comment Optional comment for the .jsonc file. 50 | */ 51 | 52 | // Info grid visible when a song is playing. 53 | // NOTE: If you wish to make changes to this, edit it in your georgia-config.jsonc file and NOT here. 54 | /** @type {MetadataGridEntry[]} */ 55 | const defaultMetadataGrid = [ 56 | { label: 'Disc', val: `$if(${tf.disc_subtitle},[Disc %discnumber% \u2013 ]${tf.disc_subtitle})` }, 57 | { label: 'Release Type', val: '$if($stricmp(%releasetype%,Album),,[%releasetype%])' }, 58 | { label: 'Year', val: '$puts(d,'+tf.date+')$if($strcmp($year($get(d)),$get(d)),$get(d),)', comment: '\'Year\' is shown if the date format is YYYY' }, 59 | { label: 'Release Date', val: '$puts(d,'+tf.date+')$if($strcmp($year($get(d)),$get(d)),,$get(d))', age: true, comment: '\'Release Date\' is shown if the date format is YYYY-MM-DD' }, 60 | { label: 'Edition', val: tf.edition }, 61 | { label: 'Label', val: '[$if($meta(label),$meta_sep(label, \u2022 ),$if3(%publisher%,%discogs_label%,))]', comment: 'The label(s) or publisher(s) that released the album.' }, 62 | { label: 'Catalog #', val: `$puts(cn,$if3(%catalognumber%,%discogs_catalog%,))[$if($get(cn),$get(cn)[ / ${tf.releaseCountry}],)]` }, 63 | { label: 'Release Country',val: `$puts(cn,$if3(%catalognumber%,%discogs_catalog%,))[$if($get(cn),,$replace(${tf.releaseCountry},XW,))]`, comment: 'Only shown if %catalognumber% or %discogs_catalog% is not present. If release country is entire world (\'XW\') value is hidden.' }, 64 | { label: 'Track', val: '$if(%tracknumber%,$num(%tracknumber%,1)$if(%totaltracks%,/$num(%totaltracks%,1))$ifgreater(%totaldiscs%,1, CD %discnumber%/$num(%totaldiscs%,1),)' }, 65 | { label: 'Genre', val: '[$meta_sep(genre, \u2022 )]' }, 66 | { label: 'Style', val: '[$meta_sep(style, \u2022 )]' }, 67 | { label: 'Release', val: '[%release%]' }, 68 | { label: 'Codec', val: "[$if($not($strstr(%codec%,'MP3')),$puts(c,$replace($if2(%codec_profile%,%codec%),ATSC A/52A,Dolby Digital))$replace($get(c),ATSC A/52,Dolby Digital)[ $replace($replace($replace($info(channel_mode), + LFE,),' front, ','/'),' rear surround channels',$if($strstr($info(channel_mode),' + LFE'),.1,.0))])]" }, 69 | { label: 'Added', val: '[$if2(%added_enhanced%,%added%)]', age: true }, 70 | { label: 'Last Played', val: '[' + tf.last_played + ']', age: true }, 71 | { label: 'Hotness', val: "$puts(X,5)$puts(Y,$div(%_dynamic_rating%,400))$repeat($repeat(I,$get(X)) ,$div($get(Y),$get(X)))$repeat(I,$mod($get(Y),$get(X)))$ifgreater(%_dynamic_rating%,0, $replace($div(%_dynamic_rating%,1000)'.'$mod($div(%_dynamic_rating%,100),10),0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9),)" }, 72 | { label: 'View Count', val: '[%fy_view_count%]' }, 73 | { label: 'Likes', val: "[$if(%fy_like_count%,%fy_like_count% \u25B2 / %fy_dislike_count% \u25BC,)]" }, 74 | { label: 'Play Count', val: '$if($or(%play_count%,%lastfm_play_count%),$puts(X,5)$puts(Y,$max(%play_count%,%lastfm_play_count%))$ifgreater($get(Y),30,,$repeat($repeat(I,$get(X)) ,$div($get(Y),$get(X)))$repeat(I,$mod($get(Y),$get(X))) )$get(Y))' }, 75 | { label: 'Rating', val: '$if(%rating%,$repeat(\u2605 ,%rating%))' }, 76 | { label: 'Mood', val: '$if(%mood%,$puts(X,5)$puts(Y,$mul(5,%mood%))$repeat($repeat(I,$get(X)) ,$div($get(Y),$get(X)))$repeat(I,$mod($get(Y),$get(X)))$replace(%mood%,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9))' }, 77 | ]; 78 | const gridSchema = new ConfigurationObjectSchema('metadataGrid', ConfigurationObjectType.Array, [ 79 | { name: 'label' }, 80 | { name: 'val' }, // todo: change this to 'value'? 81 | { name: 'age', optional: true }, 82 | ], '*NOTE* Entries that evaluate to an empty string will not be shown in the grid'); 83 | 84 | const imgPathDefaults = [ // simply add, change or re-order entries as needed 85 | '$replace(%path%,%filename_ext%,)folder*', 86 | '$replace(%path%,%filename_ext%,)front*', 87 | '$replace(%path%,%filename_ext%,)cover*', 88 | '$replace(%path%,%directoryname%\\%filename_ext%,)folder*', // all folder images in parent directory 89 | '$replace(%path%,%directoryname%\\%filename_ext%,)front*', // all folder images in parent directory 90 | '$replace(%path%,%directoryname%\\%filename_ext%,)cover*', // all folder images in parent directory 91 | '$replace(%path%,%filename_ext%,)\\trackart\\%title%.jpg', // trackart comes before secondary album art 92 | '$replace(%path%,%filename_ext%,)*.jpg', 93 | '$replace(%path%,%filename_ext%,)*.png', 94 | ]; 95 | const imgPathSchema = new ConfigurationObjectSchema('imgPaths', ConfigurationObjectType.Array, undefined, 96 | 'The titleformatting defined paths for artwork to be displayed. The first image matched will be shown first.' + 97 | ' Re-arrange, add, or remove as needed. NOTE: folder delimiters must be double-slashes ("\\\\")'); 98 | 99 | const lyricFilenamesDefaults = [ 100 | '%title%', 101 | '%artist% - %title%', 102 | '%artist% -%title%', 103 | '%tracknumber% - %title%', 104 | '%tracknumber% - %artist% - %title%', 105 | ]; 106 | const lyricFilenamesSchema = new ConfigurationObjectSchema('lyricFilenamePatterns', ConfigurationObjectType.Array, undefined, 107 | 'The titleformatting defined patterns for the names of lyrics files. Do not include file extensions. Special characters ' + 108 | 'which are not allowed in filenames (i.e. / : " etc.) will be stripped from the filenames automatically and replaced with underscores.'); 109 | 110 | const settingsDefaults = { 111 | artworkDisplayTime: 30, 112 | cdArtBasename: 'cd', 113 | defaultSortString: '$if2(%artist sort order%,%album artist%) $if3(%album sort order%,%original release date%,%date%) %album% %edition% %codec% %discnumber% %tracknumber%', 114 | extraTrackInfo: '$ifequal(%samplerate%,44100,, |$ifgreater($info(bitspersample),16, $info(bitspersample)bit,) $div(%samplerate%,1000).$left($right(%samplerate%,3),1)kHz)[ | $replace(%replaygain_album_gain%, dB,dB)]', 115 | playlistAlwaysShowBitrate: false, 116 | hideCursor: false, 117 | hidePanelBgWhenCollapsed: false, 118 | iconSet: 'updated', 119 | showDebugLog: false, 120 | showReleaseCountryFlag: true, 121 | showThemeLog: false, 122 | stoppedString1: 'foobar2000', 123 | stoppedString2: '$replace(%_foobar2000_version%,foobar2000 ,)', 124 | locked: false, 125 | } 126 | const settingsComments = { 127 | artworkDisplayTime: 'Number of seconds to show each image if more than one is found and "Cycle through all artwork" option is enabled. (Min: 5, Max: 120)', 128 | cdArtBasename: 'Do not include extension. Example: "discart", if the image provider uses that name for saving cdart and you want those filtered from showing up as albumart. Would also filter out discart1.png, etc.', 129 | defaultSortString: 'Default sort playlists generated from Library selections or clicking on playlist Hyperlinks', 130 | extraTrackInfo: 'Portion of the trackInfo in the upper right, directly under the year. Only part of the info string is customizable', 131 | playlistAlwaysShowBitrate: "Always show the codec sample rate and bitrate in all album descriptions on the Playlist.", 132 | hideCursor: 'Hides cursor when song is playing after 10 seconds of no mouse activity', 133 | hidePanelBgWhenCollapsed: 'Hide panel background when playing an album and the playlist or library view is active', 134 | iconSet: 'The function icons in the upper right. Currently valid values are \"updated\" and \"original\"', 135 | showDebugLog: 'Enables extra logging in the console. Probably not needed unless you encounter a problem or you\'re asked to enable it.', 136 | showReleaseCountryFlag: 'Shows the country flag for releases when the value specified in title_format_strings.releaseCountry is found', 137 | showThemeLog: 'Logs the output of the algorithm which determines the primary theme color.', 138 | stoppedString1: 'The bolded portion of text shown above the progress bar when nothing is playing', 139 | stoppedString2: 'The second (non-bold) portion of text shown above the progress bar when nothing is playing', 140 | locked: 'Locks theme by preventing right-clicking on the background from bringing up a menu.', 141 | } 142 | const settingsSchema = new ConfigurationObjectSchema('settings', ConfigurationObjectType.Object, 143 | // will display as key/val pairs with comments attached 144 | undefined, 'General settings for the theme.'); 145 | 146 | const transportDefaults = { 147 | displayBelowArtwork: false, 148 | enableTransportControls: true, 149 | showRandom: true, 150 | showVolume: true, 151 | showReload: false, 152 | } 153 | 154 | const transportComments = { 155 | displayBelowArtwork: 'Should the transport controls be placed below the artwork. Disabled by default.', 156 | enableTransportControls: 'Should transport controls be displayed. If false, all other transport settings are ignored.', 157 | showRandom: 'Show the randomize button', 158 | showVolume: 'Show the volume control', 159 | showReload: 'Show the reload theme button', 160 | } 161 | 162 | const transportSchema = new ConfigurationObjectSchema('transport', ConfigurationObjectType.Object, undefined, 'Transport controls settings'); 163 | -------------------------------------------------------------------------------- /js/hyperlinks.js: -------------------------------------------------------------------------------- 1 | //!@ts-check 2 | const HyperlinkStates = { 3 | Normal: 0, 4 | Hovered: 1 5 | } 6 | 7 | const measureStringScratchImg = gdi.CreateImage(1000, 200); 8 | 9 | class Hyperlink { 10 | /** 11 | * 12 | * @param {string} text The text that will be displayed in the hyperlink 13 | * @param {GdiFont} font 14 | * @param {string} type The field name which will be searched when clicking on the hyperlink 15 | * @param {number} xOffset x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify. 16 | * @param {number} yOffset y-offset of the hyperlink. 17 | * @param {number} containerWidth The width of the container the hyperlink will be in. Used for right justification purposes. 18 | * @param {boolean} [inPlaylist=false] If the hyperlink is drawing in a scrolling container like a playlist, then it is drawn differently 19 | */ 20 | constructor (text, font, type, xOffset, yOffset, containerWidth, inPlaylist = false) { 21 | this.text = text; 22 | this.type = type; 23 | this.x_offset = xOffset; 24 | if (xOffset < 0) { 25 | this.x = containerWidth + xOffset; // right justified links 26 | } else { 27 | this.x = xOffset; 28 | } 29 | this.y_offset = yOffset; 30 | this.y = yOffset; 31 | this.container_w = containerWidth; 32 | this.state = HyperlinkStates.Normal; 33 | this.inPlaylist = inPlaylist; 34 | 35 | this.setFont(font); 36 | } 37 | 38 | /** 39 | * Gets the width of the hyperlink 40 | * @return {number} The width of the hyperlink 41 | */ 42 | getWidth() { 43 | return Math.ceil(this.link_dimensions.Width); 44 | } 45 | 46 | set_y(y) { 47 | this.y = y + this.y_offset + (this.inPlaylist ? -2 : 0); // playlist requires subtracting 2 additional pixels from y for some reason 48 | } 49 | 50 | /** 51 | * Set the xOffset of the hyperlink after it has been created 52 | * @param {number} xOffset x-offset of the hyperlink. Negative values will be subtracted from the containerWidth to right justify. 53 | */ 54 | set_xOffset(xOffset) { 55 | if (xOffset < 0) { 56 | this.x = this.container_w + xOffset; // right justified links 57 | } else { 58 | this.x = xOffset; 59 | } 60 | } 61 | 62 | /** 63 | * Set the width of the container the hyperlink will be placed in. 64 | * If hyperlink width is smaller than the container, it will be truncated. 65 | * If the the xOffset is negative, the position will be adjusted as the container width changes. 66 | * @param {number} w 67 | */ 68 | setContainerWidth(w) { 69 | if (this.x_offset < 0) { 70 | this.x = w + this.x_offset; // add because offset is negative 71 | } 72 | this.container_w = w; 73 | this.link_dimensions = this.updateDimensions(); 74 | this.w = Math.ceil(Math.min(this.container_w, this.link_dimensions.Width + 1)); 75 | } 76 | 77 | // private method 78 | updateDimensions() { 79 | const gr = measureStringScratchImg.GetGraphics(); 80 | const dimensions = gr.MeasureString(this.text, this.font, 0, 0, 0, 0); 81 | this.h = Math.ceil(dimensions.Height) + 1; 82 | this.w = Math.min(Math.ceil(dimensions.Width) + 1, this.container_w); 83 | measureStringScratchImg.ReleaseGraphics(gr); 84 | return dimensions; 85 | } 86 | 87 | setFont(font) { 88 | this.font = font; 89 | this.hoverFont = gdi.Font(font.Name, font.Size, font.Style | g_font_style.underline); 90 | this.link_dimensions = this.updateDimensions(); 91 | } 92 | 93 | trace(x, y) { 94 | return (this.x <= x) && (x <= this.x + this.w) && (this.y <= y) && (y <= this.y + this.h); 95 | } 96 | 97 | /** 98 | * Draws the hyperlink. When drawing in a playlist, we draw from the y-offset instead of y, because the playlist scrolls. 99 | * @param {GdiGraphics} gr 100 | * @param {*} color 101 | */ 102 | draw(gr, color) { 103 | var font = this.state === HyperlinkStates.Hovered ? this.hoverFont : this.font; 104 | gr.DrawString(this.text, font, color, this.x, this.inPlaylist ? this.y_offset : this.y, this.w, this.h, g_string_format.trim_ellipsis_char); 105 | } 106 | 107 | repaint() { 108 | try { 109 | window.RepaintRect(this.x, this.y, this.w, this.h); 110 | } catch (e) { 111 | // probably already redrawing 112 | } 113 | } 114 | 115 | click() { 116 | const populatePlaylist = function (query) { 117 | try { 118 | const handle_list = fb.GetQueryItems(fb.GetLibraryItems(), query); 119 | debugLog(query); 120 | if (handle_list.Count) { 121 | playlistHistory.ignorePlaylistMutations = true; 122 | const pl = plman.FindOrCreatePlaylist('Search', true); 123 | plman.UndoBackup(pl); 124 | handle_list.Sort(); 125 | const index = fb.IsPlaying ? handle_list.BSearch(fb.GetNowPlaying()) : -1; 126 | if (pl === plman.PlayingPlaylist && plman.GetPlayingItemLocation().PlaylistIndex === pl && index !== -1) { 127 | // remove everything in playlist except currently playing song 128 | plman.ClearPlaylistSelection(pl); 129 | plman.SetPlaylistSelection(pl, [plman.GetPlayingItemLocation().PlaylistItemIndex], true); 130 | plman.RemovePlaylistSelection(pl, true); 131 | plman.ClearPlaylistSelection(pl); 132 | 133 | handle_list.RemoveById(index); 134 | } else { 135 | // nothing playing or Search playlist is not active 136 | plman.ClearPlaylist(pl); 137 | } 138 | plman.InsertPlaylistItems(pl, 0, handle_list); 139 | plman.SortByFormat(pl, settings.defaultSortString); 140 | plman.ActivePlaylist = pl; 141 | playlistHistory.ignorePlaylistMutations = false; 142 | return true; 143 | } 144 | return false; 145 | } catch (e) { 146 | playlistHistory.ignorePlaylistMutations = false; 147 | console.log(`Could not succesfully execute: ${query}`); 148 | } 149 | } 150 | /** @type {string} */ 151 | let query; 152 | switch (this.type) { 153 | case 'update': 154 | _.runCmd('https://github.com/kbuffington/Georgia/releases'); 155 | break; 156 | case 'date': 157 | if (pref.showPlaylistFulldate) { 158 | query = '"' + tf.date + '" IS ' + this.text; 159 | } else { 160 | query = '"$year(%date%)" IS ' + this.text; 161 | } 162 | break; 163 | case 'artist': 164 | query = `Artist HAS "${this.text.replace(/"/g,'')}" OR ARTISTFILTER HAS "${this.text.replace(/"/g,'')}"`; 165 | break; 166 | default: 167 | query = `${this.type} IS "${this.text}"`; 168 | break; 169 | } 170 | 171 | if (!populatePlaylist(query)) { 172 | var start = this.text.indexOf('['); 173 | if (start > 0) { 174 | query = this.type + ' IS ' + this.text.substr(0, start - 3); // remove ' - [...]' from end of string in case we're showing "Album - [Deluxe Edition]", etc. 175 | populatePlaylist(query); 176 | } 177 | } 178 | } 179 | } 180 | 181 | // for every Hyperlink not created in playlist 182 | function Hyperlinks_on_mouse_move (hyperlink, x, y) { 183 | var handled = false; 184 | if (hyperlink.trace(x, y)) { 185 | if (hyperlink.state !== HyperlinkStates.Hovered) { 186 | hyperlink.state = HyperlinkStates.Hovered; 187 | window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h); 188 | } 189 | handled = true; 190 | } else { 191 | if (hyperlink.state !== HyperlinkStates.Normal) { 192 | hyperlink.state = HyperlinkStates.Normal; 193 | window.RepaintRect(hyperlink.x, hyperlink.y, hyperlink.w, hyperlink.h); 194 | } 195 | } 196 | return handled; 197 | } 198 | -------------------------------------------------------------------------------- /js/image-caching.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} ArtCacheObj 3 | * @property {GdiBitmap} image 4 | * @property {number} filesize 5 | * @property {boolean} virtual 6 | */ 7 | 8 | class ArtCache { 9 | /** 10 | * Create ArtCache. ArtCache is a Least-Recently Used cache meaning that each cache hit 11 | * will bump that image to be the last image to be removed from the cache (if maxCacheSize is exceeded). 12 | * @param {number} maxCacheSize maximum number of images to keep in the cache. 13 | */ 14 | constructor(maxCacheSize) { 15 | /** @private @type {Object.} */ 16 | this.cache = {}; 17 | /** @private @type {string[]} */ 18 | this.cacheIndexes = []; 19 | /** @private */ this.cacheMaxSize = maxCacheSize; 20 | /** @private */ this.imgMaxWidth = scaleForDisplay(1440); // these are the maximum width and height an image can be displayed in Georgia 21 | /** @private */ this.imgMaxHeight = scaleForDisplay(872); 22 | } 23 | 24 | /** 25 | * Adds a rescaled image to the cache under string `location` and returns the cached image. 26 | * @param {GdiBitmap} img 27 | * @param {string} location String value to cache image under. Does not need to be a path. 28 | * @param {boolean=} virtual Is the image virtual or physically on disc. Defaults to false 29 | * @return {GdiBitmap} 30 | */ 31 | encache(img, location, virtual = false) { 32 | try { 33 | let h = img.Height; 34 | let w = img.Width; 35 | if (w > this.imgMaxWidth || h > this.imgMaxHeight) { 36 | let scaleFactor = w / this.imgMaxWidth; 37 | if (scaleFactor < h / this.imgMaxHeight) { 38 | scaleFactor = h / this.imgMaxHeight; 39 | } 40 | h = Math.min(h / scaleFactor); 41 | w = Math.min(w / scaleFactor); 42 | } 43 | if (virtual) { 44 | this.cache[location] = { image: img.Resize(w, h), filesize: 0, virtual }; 45 | } else { 46 | const f = fso.GetFile(location); 47 | this.cache[location] = { image: img.Resize(w, h), filesize: f.Size, virtual }; 48 | } 49 | img = null; 50 | const pathIndex = this.cacheIndexes.indexOf(location); 51 | if (pathIndex !== -1) { 52 | // remove from middle of cache and put on end 53 | this.cacheIndexes.splice(pathIndex, 1); 54 | } 55 | this.cacheIndexes.push(location); 56 | if (this.cacheIndexes.length > this.cacheMaxSize) { 57 | const remove = this.cacheIndexes.shift(); 58 | debugLog('Removing img from cache:', remove); 59 | delete this.cache[remove]; 60 | } 61 | } catch (e) { 62 | console.log(''); 63 | } 64 | if (this.cache[location]) { 65 | return this.cache[location].image; 66 | } 67 | return img; 68 | } 69 | 70 | /** 71 | * Get cached image if it exists under the location string. If image is found, move it's index to the end of the cacheIndexes. 72 | * @param {string} location String value to check if image is cached under. 73 | * @return {GdiBitmap} 74 | */ 75 | getImage(location) { 76 | const cacheObj = this.cache[location]; 77 | let f; 78 | if (cacheObj) { 79 | if (!cacheObj.virtual) { 80 | f = fso.GetFile(location); 81 | } 82 | const pathIndex = this.cacheIndexes.indexOf(location); 83 | this.cacheIndexes.splice(pathIndex, 1); 84 | if (!f || f.Size === cacheObj.filesize) { 85 | this.cacheIndexes.push(location); 86 | debugLog('cache hit:', location); 87 | return cacheObj.image; 88 | } else { 89 | // size of file on disk has changed 90 | debugLog(`cache entry was stale: ${location} [old size: ${cacheObj.filesize}, new size: ${f.Size}]`); 91 | delete this.cache[location]; // was removed from cacheIndexes already 92 | } 93 | } 94 | return null; 95 | } 96 | 97 | /** 98 | * Completely clear all cached entries and release memory held by scaled bitmaps. 99 | */ 100 | clear() { 101 | while (this.cacheIndexes.length) { 102 | const remove = this.cacheIndexes.shift(); 103 | this.cache[remove] = null; 104 | delete this.cache[remove]; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /js/lyrics.js: -------------------------------------------------------------------------------- 1 | // Lyrics Variables 2 | const len_seconds = fb.TitleFormat('%length_seconds%'); 3 | 4 | const LYRICS_TIMER_INTERVAL = 30; // do not modify this value 5 | const SCROLL_TIME = 300; // max time in ms for new line to scroll 6 | const SCROLL_WHEEL_TIME_OFFSET = 500; // amount of time (ms) to adjust lyrics when scrolling 7 | const OFFSET_DISPLAY_TIME = 5000; // time in ms to display scroll offset at the top of the lyrics area 8 | const LYRICS_PADDING = 24; // padding between edge of artwork and the lyrics 9 | const NO_LYRICS_STRING = 'No lyrics found'; // what to show when no lyrics exist 10 | const LYRICS_NOT_FOUND_STRING = 'Search completed\n \nNo lyrics were found'; // what to show when no lyrics exist 11 | const SEARCHING_LYRICS_STRING = 'Searching for lyrics... \n \nPlease wait...'; // what to show when searching for lyrics 12 | 13 | const lyricShow3loaded = utils.CheckComponent("foo_uie_lyrics3"); 14 | 15 | /** 16 | * @typedef {Object} LineObj 17 | * @property {string} lyric the text of the line 18 | * @property {string} timeStamp the timestamp string 19 | * @property {float} time the timestamp as a float value in seconds 20 | * @property {number} timeMs the timestamp as an integer in milliseconds 21 | * @property {boolean} focus does the line have focus 22 | */ 23 | 24 | class Line { 25 | constructor(lyricJson) { 26 | this.time = 0; 27 | this.timeStamp = ''; 28 | this.lyric = ''; 29 | this.focus = false; 30 | Object.assign(this, lyricJson); 31 | this.timeMs = Math.round(this.time * 1000); 32 | this.lines = 0; 33 | this.width = 0; 34 | this.height = 0; 35 | this.y = 0; 36 | } 37 | 38 | /** 39 | * @param {GdiGraphics} gr 40 | * @param {number} w Width 41 | * @param {number} h Height 42 | * @param {number} minHeight minimum height of a line (used for blank lines) 43 | * @param {number} yPosition yVal of the line in the list of lyrics 44 | */ 45 | calcSize(gr, w, h, minHeight, yPosition) { 46 | const strInfo = gr.MeasureString(this.lyric, ft.lyrics, 0, 0, w, h); 47 | this.lines = strInfo.Lines; 48 | this.width = strInfo.Width; 49 | this.height = Math.min(Math.max(minHeight, strInfo.Height), h / 2); // at least minHeight (for blank lines) and no more than half artwork height 50 | this.y = yPosition; 51 | } 52 | 53 | /** 54 | * @param {GdiGraphics} gr 55 | * @param {number} yOffset 56 | */ 57 | draw(gr, x, width, yOffset, highlightActive) { 58 | const color = highlightActive && this.focus ? g_txt_highlightcolour : g_txt_normalcolour; 59 | const center = StringFormat(1, 1, 4); // center with ellipses 60 | 61 | // drop shadow behind text 62 | gr.DrawString(this.lyric, ft.lyrics, g_txt_shadowcolor, x - 1, this.y + yOffset, width, this.height + 1, center); 63 | gr.DrawString(this.lyric, ft.lyrics, g_txt_shadowcolor, x, this.y + yOffset - 1, width, this.height + 1, center); 64 | gr.DrawString(this.lyric, ft.lyrics, g_txt_shadowcolor, x + 2, this.y + yOffset + 2, width, this.height + 1, center); 65 | // text 66 | gr.DrawString(this.lyric, ft.lyrics, color, x, this.y + yOffset, width, this.height + 1, center); 67 | } 68 | } 69 | 70 | /** @enum {number} */ 71 | const LyricsType = { 72 | None: 0, 73 | Synced: 1, 74 | Unsynced: 2 75 | } 76 | 77 | const timeStampRegex = /^(\s*\[\d{1,2}:\d\d(]|\.\d{1,3}]))+/; 78 | const singleTimestampRegex = /^\s*(\[\d{1,2}:\d\d(]|\.\d{1,3}]))/; 79 | 80 | /** @type {Lyrics} */ 81 | let gLyrics; 82 | 83 | class Lyrics { 84 | /** 85 | * @param {FbMetadbHandle} metadb 86 | * @param {?*} lyrics User specified lyrics 87 | */ 88 | constructor(metadb, lyrics = undefined) { 89 | this.metadb = metadb; 90 | this.fileName = ''; 91 | this.x = 0; 92 | this.y = 0; 93 | this.w = 0; 94 | this.h = 0; 95 | this.lyricsType = LyricsType.None; 96 | 97 | /** @protected */ this.songLength = parseInt(len_seconds.Eval()); 98 | /** @protected {Line[]} */ 99 | this.lines = []; 100 | /** @protected */ this.activeLine = -1; // index into this.lines 101 | /** @protected */ this.scrolling = false; 102 | /** @protected */ this.scrollOffset = 0; 103 | /** @protected */ this.scrollStep = 0; // when scrolling to a new value, how much should we scroll 104 | /** @protected */ this.timeOffset = 0; 105 | /** @protected */ this.lineSpacing = scaleForDisplay(10); 106 | /** @protected */ this.lineHeight = 0; 107 | /** @protected */ this.timerId = 0; 108 | /** @protected */ this.loadingTimerId = 0; // timer when loading embedded lyrics 109 | /** @protected */ this.showOffsetTimerId = 0; // timer to hide offset 110 | /** @protected */ this.showOffset = false; 111 | /** @protected */ this.lyricsSearchTimer = 0; 112 | /** @protected */ this.searchTimeoutTimer = 0; 113 | 114 | this.loadLyrics(); 115 | if (fb.IsPlaying) { 116 | this.seek(); 117 | if (!fb.IsPaused) { 118 | this.startTimer(); 119 | } 120 | } 121 | } 122 | 123 | // Callbacks 124 | on_size(x, y, w, h) { 125 | this.x = x + LYRICS_PADDING; 126 | this.y = y + LYRICS_PADDING; 127 | this.w = w - LYRICS_PADDING * 2; // should width/height be split? 128 | this.h = h - LYRICS_PADDING * 2; 129 | this.lineSpacing = scaleForDisplay(10); 130 | if (this.lines.length && this.w > 10 && this.h > 100) { 131 | const tmpImg = gdi.CreateImage(this.w, Math.round(this.h / 5)); 132 | const gr = tmpImg.GetGraphics(); 133 | const minHeight = gr.MeasureString('I', ft.lyrics, 0, 0, this.w, this.h).Height; 134 | for (let i = 0, yPos = 0; i < this.lines.length; i++) { 135 | this.lines[i].calcSize(gr, this.w, this.h, minHeight, yPos); 136 | yPos += this.lines[i].height + this.lineSpacing; 137 | } 138 | tmpImg.ReleaseGraphics(gr); 139 | } 140 | this.repaint(); 141 | } 142 | 143 | 144 | on_playback_pause(state) { 145 | if (state) { 146 | this.clearTimer(); 147 | } else { // unpausing 148 | this.startTimer(); 149 | } 150 | } 151 | 152 | on_playback_stop(reason) { 153 | this.clearTimer(); 154 | this.lines = []; 155 | } 156 | 157 | on_mouse_wheel(delta) { 158 | if (delta > 0) { 159 | this.timeOffset -= SCROLL_WHEEL_TIME_OFFSET; 160 | } else { 161 | this.timeOffset += SCROLL_WHEEL_TIME_OFFSET; 162 | } 163 | this.showOffset = this.timeOffset !== 0; 164 | clearTimeout(this.showOffsetTimerId); 165 | this.showOffsetTimerId = setTimeout(() => { 166 | this.showOffset = false; 167 | this.repaint(); 168 | }, OFFSET_DISPLAY_TIME); 169 | this.seek(); 170 | } 171 | 172 | clearTimer() { 173 | if (this.timerId) { 174 | clearInterval(this.timerId); 175 | this.timerId = 0; 176 | } 177 | clearTimeout(this.loadingTimerId); 178 | clearTimeout(this.showOffsetTimerId); 179 | } 180 | 181 | startTimer() { 182 | this.clearTimer(); 183 | this.timerId = setInterval(() => { this.timerTick(); }, LYRICS_TIMER_INTERVAL); 184 | } 185 | 186 | /** 187 | * Searches through config file's list of lyric paths and file patterns to find lyrics files 188 | * @returns {boolean} 189 | */ 190 | findLyrics() { 191 | let foundLyrics = false; 192 | const tpath = []; 193 | const tfilename = []; 194 | 195 | const stripReservedChars = (filename) => { 196 | return filename.replace(/[<>:"/\\|?*]/g, "_") 197 | } 198 | 199 | tf.lyr_path.forEach(path => { 200 | tpath.push($(path)); 201 | }) 202 | globals.lyricFilenamePatterns.forEach(filename => { 203 | tfilename.push(stripReservedChars($(filename))); 204 | }); 205 | 206 | for (let i = 0; i < tpath.length && !foundLyrics; i++) { 207 | for (let j = 0; j < tfilename.length; j++) { 208 | foundLyrics = this.checkFile(tpath[i], tfilename[j]); 209 | if (foundLyrics) { 210 | break; 211 | } 212 | } 213 | } 214 | 215 | return foundLyrics; 216 | } 217 | 218 | loadLyrics() { 219 | let rawLyrics = []; 220 | const foundLyrics = this.findLyrics(); 221 | if (foundLyrics) { 222 | console.log('Found Lyrics:', this.fileName); 223 | rawLyrics = utils.ReadTextFile(this.fileName, 65001).split('\n'); 224 | } else { 225 | const embeddedLyrics = $(tf.lyrics); 226 | if (embeddedLyrics.length) { 227 | if (embeddedLyrics === '.') { 228 | rawLyrics = [ 229 | 'Lyrics cannot be displayed.', 230 | 'For %LYRICS% or %UNSYNCED LYRICS% to always display properly, you must edit LargeFieldsConfig.txt and comment out or remove those specific entries under "fieldSpam"' 231 | ]; 232 | } else { 233 | rawLyrics = embeddedLyrics.split('\n'); 234 | if (rawLyrics.length === 1) { 235 | rawLyrics = embeddedLyrics.split('\r'); 236 | } 237 | } 238 | } 239 | } 240 | if (rawLyrics.length) { 241 | this.processLyrics(rawLyrics); 242 | } else { 243 | // no lyrics found locally 244 | if (lyricShow3loaded) { 245 | this.searchingLyrics(); 246 | } else { 247 | this.processLyrics([NO_LYRICS_STRING]); 248 | } 249 | } 250 | } 251 | 252 | searchingLyrics() { 253 | this.processLyrics([SEARCHING_LYRICS_STRING]); 254 | this.lyricShow3save(fb.GetNowPlaying()); 255 | clearTimeout(this.searchTimeout); 256 | this.searchTimeout = setTimeout(() => { 257 | if (!this.findLyrics()) { 258 | this.processLyrics([LYRICS_NOT_FOUND_STRING]); 259 | this.on_size(albumart_size.x, albumart_size.y, albumart_size.w, albumart_size.h); 260 | } 261 | clearInterval(this.lyricsSearchTimer); 262 | }, 15000); 263 | } 264 | 265 | // Automatic Lyric Show 3 File Saver 266 | lyricShow3save(metadb) { 267 | if (!lyricShow3loaded) return; 268 | clearInterval(this.lyricsSearchTimer); 269 | if (!metadb) 270 | return; 271 | this.lyricsSearchTimer = setInterval(() => { 272 | if (this.findLyrics()) { 273 | clearInterval(this.lyricsSearchTimer); 274 | initLyrics(); 275 | } else { 276 | fb.RunMainMenuCommand('View/Lyrics Show 3/Save'); 277 | } 278 | }, 1000); 279 | } 280 | 281 | /** 282 | * Sets the focus line. Should be called when playback starts, or whenever seeking in the file 283 | */ 284 | seek() { 285 | const time = Math.round(fb.PlaybackTime * 1000) + this.timeOffset; 286 | this.lines.forEach(l => l.focus = false); 287 | const index = this.lines.findIndex(l => l.timeMs >= time); 288 | this.activeLine = index === -1 ? this.lines.length - 1 : Math.max(0, index - 1); // if time > all timeMs values, then we're on the last line of the song, otherwise choose previous line 289 | if (this.activeLine >= 0) { 290 | this.lines[this.activeLine].focus = true; 291 | this.repaint(); 292 | } 293 | } 294 | 295 | /** 296 | * Checks if lyrics file exists at path+filename and sets this.fileName if it does 297 | * @param {string} path 298 | * @param {string} filename 299 | */ 300 | checkFile(path, filename) { 301 | var found = true; 302 | if (IsFile(path + filename + '.lrc')) { 303 | this.fileName = path + filename + '.lrc'; 304 | } else if (IsFile(path + filename + '.txt')) { 305 | this.fileName = path + filename + '.txt'; 306 | } else { 307 | found = false; 308 | } 309 | return found; 310 | } 311 | 312 | /** 313 | * @param {String[]} rawLyrics 314 | */ 315 | processLyrics(rawLyrics) { 316 | let tsCount = 0; 317 | const noLyrics = rawLyrics[0] === NO_LYRICS_STRING; 318 | 319 | rawLyrics.forEach(line => { 320 | if (timeStampRegex.test(line)) { 321 | tsCount++; 322 | } 323 | }) 324 | if (tsCount > rawLyrics.length * .3 && !noLyrics) { 325 | this.lyricsType = LyricsType.Synced; 326 | } 327 | let lyrics = [{ timeStamp: '00:00.00', time: 0, lyric: noLyrics ? NO_LYRICS_STRING : '' }]; 328 | if (this.lyricsType === LyricsType.Synced) { 329 | rawLyrics.forEach(line => { 330 | const r = timeStampRegex.exec(line); 331 | if (r && r[0]) { 332 | // line has at least one timestamp 333 | let timestampStr = r[0]; 334 | const lyric = replaceUnicodeChars(line.substr(timestampStr.length)); 335 | 336 | let ts; 337 | while (timestampStr.length && (ts = singleTimestampRegex.exec(timestampStr))) { 338 | timestampStr = timestampStr.substr(ts[0].length); 339 | const timeComponents = ts[0].trim().replace('[','').replace(']','').split(':'); 340 | const time = (parseInt(timeComponents[0]) * 60) + parseFloat(timeComponents[1]); 341 | lyrics.push({ timeStamp: ts[0], time, lyric }); 342 | } 343 | } 344 | }); 345 | } else if (!noLyrics) { 346 | this.lyricsType = LyricsType.Unsynced; 347 | const unsyncedScrollDelay = Math.max(Math.floor(this.songLength * .08), 10); // num seconds to wait before scrolling at start of song. 348 | const availSecs = this.songLength - unsyncedScrollDelay * 2; 349 | const lineTiming = availSecs / rawLyrics.length; 350 | rawLyrics.forEach((line, i) => { 351 | const lyric = replaceUnicodeChars(line); 352 | const time = unsyncedScrollDelay + lineTiming * i; 353 | lyrics.push({ timeStamp: '--', time, lyric }); 354 | }); 355 | let done = false; 356 | while (lyrics.length && !done) { 357 | // remove all empty trailing lines 358 | if (!lyrics[lyrics.length - 1].lyric.length) { 359 | lyrics.pop(); 360 | } else { 361 | done = true; 362 | } 363 | } 364 | } 365 | this.lines = lyrics.sort((a, b) => a.time - b.time).map(lyric => new Line(lyric)); 366 | } 367 | 368 | timerTick() { 369 | /** @type {float} */ 370 | const time = Math.round(fb.PlaybackTime * 1000) + this.timeOffset; 371 | if ((this.lines.length > this.activeLine + 1) && (time > this.lines[this.activeLine + 1].timeMs)) { 372 | // advance active Line 373 | this.scrolling = true; 374 | if (this.activeLine !== -1) { 375 | this.lines[this.activeLine].focus = false; 376 | this.scrollOffset = this.lines[this.activeLine].height + this.lineSpacing; // scrollOffset is actually the previously activeline that we want to scroll out of the way 377 | this.scrollStep = Math.max(1, Math.round(this.scrollOffset / (SCROLL_TIME / LYRICS_TIMER_INTERVAL))); 378 | } else { 379 | this.scrollOffset = 0; 380 | } 381 | this.lines[++this.activeLine].focus = true; 382 | } else if (this.scrolling) { 383 | this.scrollOffset = Math.max(0, this.scrollOffset - this.scrollStep); 384 | if (this.scrollOffset <= 0) { 385 | this.scrolling = false; 386 | } 387 | this.repaint(); 388 | } else { 389 | // otherwise nothing to do this tick 390 | } 391 | } 392 | 393 | /** 394 | * @param {GdiGraphics} gr 395 | */ 396 | drawLyrics(gr) { 397 | if (this.lines.length && this.activeLine >= 0) { 398 | let activeTop = Math.floor(this.h * .37); // position of the active line 399 | let extraSpacing = Math.floor(this.h * .26) * (this.activeLine / this.lines.length); 400 | activeTop += this.lines.length > 9 ? extraSpacing : 0; // adjusting position looks dumb if very few lines 401 | const activeY = this.lines[this.activeLine].y; 402 | 403 | const viewportTop = activeY - activeTop; 404 | const highlightActive = this.lyricsType !== LyricsType.Unsynced; // highlight no lyrics text 405 | this.lines.forEach(l => { 406 | if (l.y > viewportTop && l.y + l.height < this.h + viewportTop) { 407 | l.draw(gr, this.x, this.w, this.y - viewportTop + this.scrollOffset, highlightActive); 408 | } 409 | }); 410 | if (this.lyricsType === LyricsType.Synced && this.timeOffset && this.showOffset) { 411 | gr.DrawString(`Offset: ${this.timeOffset / 1000}s`, ft.lyrics, g_txt_highlightcolour, this.x, this.y, this.w, this.h + 1, StringFormat(2, 0)); 412 | } 413 | } 414 | } 415 | 416 | repaint() { 417 | window.RepaintRect(this.x - 2, this.y - 2, this.w + 4, this.h + 4); 418 | } 419 | } 420 | 421 | /** 422 | * Load lyrics of NowPlaying song, and sets size of the lyrics draw area 423 | */ 424 | function initLyrics() { 425 | gLyrics = new Lyrics(fb.GetNowPlaying()); 426 | if (gLyrics.lyricsType === LyricsType.None) { 427 | this.loadingTimerId = setTimeout(() => { 428 | gLyrics.loadLyrics(); 429 | gLyrics.seek(); 430 | gLyrics.on_size(albumart_size.x, albumart_size.y, albumart_size.w, albumart_size.h); 431 | }, 500); 432 | } 433 | gLyrics.on_size(albumart_size.x, albumart_size.y, albumart_size.w, albumart_size.h); 434 | } 435 | 436 | /** 437 | * Strips out unicode characters such as apostrophes which will print as crap in the lyrics. 438 | * May not be needed when using UTF-8 code page 439 | * @param {*} rawString 440 | */ 441 | function replaceUnicodeChars(rawString) { 442 | return rawString.trim() 443 | .replace(/\u2019/g,"'") 444 | .replace(/\uFF07/g,"'") 445 | .replace(/\u00E2\u20AC\u2122/g, "'"); // replace apostrophes 446 | } -------------------------------------------------------------------------------- /js/playlist-history.js: -------------------------------------------------------------------------------- 1 | const PlaylistMutation = { 2 | Added: 'added', 3 | Init: 'initializing playlist history', 4 | Removed: 'removed', 5 | Reordered: 'reordered', 6 | Switch: 'switch', 7 | } 8 | 9 | class PlaylistHistory { 10 | constructor(maxStates = 10) { 11 | this.maxStates = maxStates; 12 | /** @private PlaylistState[] */ this.history = []; 13 | /** @private */ this.stateIndex = 0; 14 | /** @private */ this.updatingPlaylist = false; 15 | 16 | this.playlistAltered(PlaylistMutation.Init); 17 | } 18 | 19 | get length() { 20 | return this.history.length; 21 | } 22 | 23 | canBack() { 24 | return this.stateIndex > 0; 25 | } 26 | 27 | canForward() { 28 | return this.stateIndex < this.length - 1; 29 | } 30 | 31 | /** 32 | * Sets whether the history should ignore upcoming mutations and changes to the playlist. 33 | * 34 | * Playlist updates are synchronous, but notifications are async. If setting to false, we 35 | * update that value async as well to hopefully happen after all callbacks have called, and 36 | * and then manually call playlistAltered in case the playlist state has changed. 37 | * @param {boolean} ignore 38 | */ 39 | set ignorePlaylistMutations(ignore) { 40 | if (!ignore) { 41 | setTimeout(() => { 42 | this.updatingPlaylist = false; 43 | this.playlistAltered(PlaylistMutation.Switch); 44 | }, 1); 45 | } else { 46 | this.updatingPlaylist = true; 47 | } 48 | } 49 | 50 | back() { 51 | this.stateIndex--; 52 | if (this.stateIndex <= 0) { 53 | this.stateIndex = 0; 54 | } 55 | debugLog('playlistHistory back =>', this.stateIndex); 56 | this.setPlaylistState(); 57 | } 58 | 59 | forward() { 60 | this.stateIndex++; 61 | if (this.stateIndex >= this.length) { 62 | this.stateIndex = this.length - 1; 63 | } 64 | debugLog('playlistHistory forward =>', this.stateIndex); 65 | this.setPlaylistState(); 66 | } 67 | 68 | /** 69 | * Call this to clear the history. Should always be called from on_playlists_changed 70 | * because all saved playlistIds have been invalidated. 71 | */ 72 | reset() { 73 | this.history = []; 74 | this.playlistAltered(PlaylistMutation.Init); 75 | } 76 | 77 | /** @private */ 78 | setPlaylistState() { 79 | this.updatingPlaylist = true; 80 | /** @type PlaylistState */ const activeState = this.history[this.stateIndex]; 81 | const pbQueue = plman.GetPlaybackQueueContents(); 82 | const plIndex = activeState.playlistId 83 | plman.UndoBackup(plIndex); 84 | plman.ActivePlaylist = plIndex; 85 | if (!activeState.locked) { 86 | const playingItem = plman.GetPlayingItemLocation(); 87 | if (!playingItem.IsValid || playingItem.PlaylistIndex !== plIndex) { 88 | plman.ClearPlaylist(plIndex); 89 | plman.InsertPlaylistItems(plIndex, 0, activeState.playlistEntries); 90 | } else { 91 | const handles = plman.GetPlaylistItems(plIndex); 92 | const index = handles.Find(fb.GetNowPlaying()); 93 | const stateHandles = activeState.playlistEntries.Clone(); 94 | const stateIndex = stateHandles.Find(fb.GetNowPlaying()); 95 | const stateHandlesClone = stateHandles.Clone(); 96 | console.log('>>> now playing index:', index); 97 | // remove everything in playlist except currently playing song 98 | plman.ClearPlaylistSelection(plIndex); 99 | plman.SetPlaylistSelection(plIndex, [playingItem.PlaylistItemIndex], true); 100 | plman.RemovePlaylistSelection(plIndex, true); 101 | plman.ClearPlaylistSelection(plIndex); 102 | try { 103 | stateHandles.RemoveById(stateIndex); 104 | } catch (e) { 105 | plman.InsertPlaylistItems(plIndex, plman.PlaylistItemCount(plIndex), stateHandlesClone); 106 | } 107 | if (stateIndex > 0) { 108 | stateHandles.RemoveRange(stateIndex, stateHandles.Count); 109 | plman.InsertPlaylistItems(plIndex, 0, stateHandles); 110 | } 111 | if (stateIndex < stateHandlesClone.Count) { 112 | stateHandlesClone.RemoveRange(0, stateIndex); 113 | plman.InsertPlaylistItems(plIndex, plman.PlaylistItemCount(plIndex), stateHandlesClone); 114 | } 115 | } 116 | } 117 | this.restorePlaybackQueue(pbQueue); 118 | setTimeout(() => { 119 | this.updatingPlaylist = false; 120 | }, 1); // wait for callbacks to be called 121 | } 122 | 123 | /** 124 | * @private Attempts to re-mark playbackQueue items after setting playlist state 125 | * @param {FbPlaybackQueueItem[]} pbQueue 126 | */ 127 | restorePlaybackQueue(pbQueue) { 128 | plman.FlushPlaybackQueue(); 129 | pbQueue.forEach((queueItem) => { 130 | const itemPlaylist = queueItem.PlaylistIndex; 131 | const itemIndex = queueItem.PlaylistItemIndex; 132 | if (itemPlaylist !== -1 && itemIndex !== -1) { 133 | const plContents = {}; 134 | if (!plContents[itemPlaylist]) { 135 | plContents[itemPlaylist] = plman.GetPlaylistItems(itemPlaylist); 136 | } 137 | /** FbMetadbHandleList */ const playlistHandles = plContents[itemPlaylist]; 138 | if (playlistHandles && playlistHandles[itemIndex] && playlistHandles[itemIndex].Path === queueItem.Handle.Path) { 139 | plman.AddPlaylistItemToPlaybackQueue(itemPlaylist, itemIndex); 140 | } else { 141 | const index = plContents[itemPlaylist].Find(queueItem.Handle); 142 | if (index >= 0) { 143 | plman.AddPlaylistItemToPlaybackQueue(itemPlaylist, index); 144 | } else { 145 | plman.AddItemToPlaybackQueue(queueItem.Handle); 146 | } 147 | } 148 | } else { 149 | plman.AddItemToPlaybackQueue(queueItem.Handle); 150 | } 151 | }); 152 | } 153 | 154 | /** 155 | * Notify the PlaylistHistory that a playlist was altered. 156 | * @param {string} mutationType 157 | */ 158 | playlistAltered(mutationType) { 159 | // ignore playlist alterations when changing states 160 | console.log(mutationType); 161 | if (!this.updatingPlaylist && plman.ActivePlaylist >= 0) { 162 | const plItems = plman.GetPlaylistItems(plman.ActivePlaylist); 163 | if (this.shouldAddState(plman.ActivePlaylist, plItems, mutationType)) { 164 | if (this.stateIndex < this.length - 1) { 165 | this.history = this.history.slice(0, this.stateIndex + 1); 166 | } 167 | if (this.length >= this.maxStates) { 168 | this.history.shift(); 169 | } 170 | this.history.push(new PlaylistState(plman.ActivePlaylist, plItems)); 171 | this.stateIndex = this.length - 1; 172 | if (btns.back) { 173 | btns.back.repaint(); 174 | btns.forward.repaint(); 175 | } 176 | debugLog('stateIndex:', this.stateIndex, ' new items count:', plItems.Count, this.stateIndex); 177 | } 178 | } 179 | } 180 | 181 | /** 182 | * @private Determine if a new state should be added to the playlistHistory 183 | * @param {number} playlistId 184 | * @param {FbMetadbHandleList} newItems List of handles of playlist items 185 | * @param {string} mutationType currently unused 186 | * @returns {boolean} 187 | */ 188 | shouldAddState(playlistId, newItems, mutationType) { 189 | const start = Date.now(); 190 | const currState = this.history[this.stateIndex]; 191 | if (!currState) { 192 | // init'ing playlist history 193 | return true 194 | } 195 | 196 | // if playlist ID is unchanged, and playlist is locked, don't save 197 | if (playlistId === currState.playlistId && plman.IsPlaylistLocked(playlistId)) { 198 | return false; 199 | } 200 | if (playlistId !== currState.playlistId || 201 | currState.locked || plman.IsPlaylistLocked(playlistId) || 202 | newItems.Count !== currState.playlistEntries.Count) { 203 | return true; 204 | } 205 | for (let i = 0; i < newItems.Count; i++) { 206 | if (newItems[i].RawPath !== currState.playlistEntries[i].RawPath) { 207 | // console.log(newItems[i].RawPath, currState.playlistEntries[i].RawPath); 208 | return true; 209 | } 210 | } 211 | debugLog(`Checking for duplicate playlist states took: ${Date.now() - start}ms`); 212 | return false; 213 | } 214 | } 215 | 216 | /** 217 | * @class 218 | * @constructor 219 | * @public 220 | */ 221 | class PlaylistState { 222 | /** 223 | * @param {number} playlistId 224 | * @param {FbMetadbHandleList} plItems 225 | */ 226 | constructor(playlistId, plItems) { 227 | /** 228 | * @type {number} 229 | * @public 230 | */ 231 | this.playlistId = playlistId; 232 | /** 233 | * @type {boolean} 234 | * @public 235 | */ 236 | this.locked = plman.IsPlaylistLocked(playlistId); 237 | if (!this.locked) { 238 | // don't need to save items if playlist is locked, we'll just switch to it 239 | /** @type {FbMetadbHandleList} */ this.playlistEntries = plItems; 240 | } 241 | } 242 | } -------------------------------------------------------------------------------- /js/themes.js: -------------------------------------------------------------------------------- 1 | var themeArray = []; 2 | 3 | const redTheme = { 4 | name: 'salmon/brightred', 5 | colors: { 6 | primary: rgb(235, 70, 80), 7 | darkAccent: rgb(170, 26, 42), 8 | accent: rgb(206, 58, 72), 9 | lightAccent: rgb(238, 135, 146), 10 | }, 11 | hint: [rgb(235, 70, 80), rgb(240,230,220)] 12 | }; 13 | 14 | const blueTheme = { 15 | name: 'blue', 16 | colors: { 17 | primary: rgb(40, 57, 99), 18 | darkAccent: rgb(21, 36, 74), 19 | accent: rgb(61, 78, 120), 20 | lightAccent: rgb(97, 112, 148), 21 | }, 22 | hint: [rgb(40, 57, 99), rgb(220,230,240)] 23 | }; 24 | 25 | const midnightBlueTheme = { 26 | name: 'midnightBlue', 27 | colors: { 28 | primary: rgb(0, 0, 48), 29 | darkAccent: rgb(0, 0, 32), 30 | accent: rgb(31, 31, 92), 31 | lightAccent: rgb(64, 64, 116), 32 | }, 33 | hint: [rgb(0, 0, 48)] 34 | }; 35 | 36 | const blackTheme = { 37 | name: 'black', 38 | colors: { 39 | primary: rgb(10,10,10), 40 | darkAccent: rgb(32, 32, 32), 41 | accent: rgb(56, 56, 56), 42 | lightAccent: rgb(78, 78, 78), 43 | }, 44 | hint: [rgb(0, 0, 0)] 45 | }; 46 | 47 | 48 | function setTheme(theme) { 49 | var themeCol = new Color(theme.primary); 50 | if (colorDistance(theme.primary, col.bg, true) < (themeCol.isCloseToGreyscale ? 60 : 45)) { 51 | if (pref.darkMode) { 52 | if (settings.showThemeLog) console.log('>>> Theme primary color is too close to bg color. Tinting theme color.'); 53 | theme.primary = tintColor(theme.primary, 5); 54 | themeCol = new Color(theme.primary); 55 | } else { 56 | if (settings.showThemeLog) console.log('>>> Theme primary color is too close to bg color. Shading theme color.'); 57 | theme.primary = shadeColor(theme.primary, 5); 58 | themeCol = new Color(theme.primary); 59 | } 60 | } 61 | col.primary = theme.primary; 62 | 63 | if (pref.darkMode) { 64 | col.progress_bar = rgb(23, 22, 25); 65 | } else { 66 | col.progress_bar = rgb(125,125,125); 67 | } 68 | if (colorDistance(theme.primary, col.progress_bar, true) < (themeCol.isCloseToGreyscale ? 60 : 45)) { 69 | // progress fill is too close in color to bg 70 | if (settings.showThemeLog) console.log('>>> Theme primary color is too close to progress bar. Adjusting progress_bar'); 71 | if (themeCol.brightness < 125) { 72 | col.progress_bar = rgb(138,138,138); 73 | } else { 74 | col.progress_bar = rgb(112,112,112); 75 | } 76 | } 77 | if (str.timeline) { 78 | str.timeline.setColors(theme.darkAccent, theme.accent, theme.lightAccent); 79 | } 80 | col.tl_added = theme.darkAccent; 81 | col.tl_played = theme.accent; 82 | col.tl_unplayed = theme.lightAccent; 83 | 84 | col.primary = theme.primary; 85 | col.extraDarkAccent = shadeColor(theme.primary, 50); 86 | col.darkAccent = theme.darkAccent; 87 | col.accent = theme.accent; 88 | col.lightAccent = theme.lightAccent; 89 | } 90 | 91 | /** 92 | * @param {GdiBitmap} image 93 | * @param {number} maxColorsToPull 94 | */ 95 | function getThemeColorsJson(image, maxColorsToPull) { 96 | let selectedColor; 97 | const minFrequency = 0.015; 98 | const maxBrightness = pref.darkMode ? 255 : 212; 99 | 100 | try { 101 | let colorsWeighted = JSON.parse(image.GetColourSchemeJSON(maxColorsToPull)); 102 | colorsWeighted.map(c => { 103 | c.col = new Color(c.col); 104 | }); 105 | 106 | if (settings.showThemeLog) console.log('idx color bright freq weight'); 107 | let maxWeight = 0; 108 | selectedColor = colorsWeighted[0].col; // choose first color in case no color selected below 109 | colorsWeighted.forEach((c, i) => { 110 | const col = c.col; 111 | const midBrightness = 127 - Math.abs(127 - col.brightness); // favors colors with a brightness around 127 112 | c.weight = c.freq * midBrightness * 10; // multiply by 10 so numbers are easier to compare 113 | 114 | if (c.freq >= minFrequency && !col.isCloseToGreyscale && col.brightness < maxBrightness) { 115 | if (settings.showThemeLog) console.log(leftPad(i, 2), col.getRGB(true,true), leftPad(col.brightness, 4), ' ', leftPad((c.freq*100).toFixed(2),5) + '%', leftPad(c.weight.toFixed(2), 7)); 116 | if (c.weight > maxWeight) { 117 | maxWeight = c.weight; 118 | selectedColor = col; 119 | } 120 | } else if (col.isCloseToGreyscale) { 121 | if (settings.showThemeLog) console.log(' -', col.getRGB(true,true), leftPad(col.brightness, 4), ' ', leftPad((c.freq*100).toFixed(2),5) + '%', ' grey'); 122 | } else { 123 | if (settings.showThemeLog) console.log(' -', col.getRGB(true,true), leftPad(col.brightness, 4), ' ', leftPad((c.freq*100).toFixed(2),5) + '%', 124 | (c.freq < minFrequency) ? ' freq' : ' bright'); 125 | } 126 | }); 127 | 128 | if (selectedColor.brightness < 37) { 129 | if (settings.showThemeLog) console.log(selectedColor.getRGB(true), 'brightness:', selectedColor.brightness, 'too dark -- searching for highlight color'); 130 | let brightest = selectedColor; 131 | maxWeight = 0; 132 | colorsWeighted.forEach(c => { 133 | if (c.col.brightness > selectedColor.brightness && 134 | c.col.brightness < 200 && 135 | !c.col.isCloseToGreyscale && 136 | c.weight > maxWeight && 137 | c.freq > .01) { 138 | maxWeight = c.weight; 139 | brightest = c.col; 140 | } 141 | }); 142 | selectedColor = brightest; 143 | } 144 | if (settings.showThemeLog) console.log('Selected Color:', selectedColor.getRGB(true)); 145 | return selectedColor.val; 146 | } catch (e) { 147 | console.log(''); 148 | } 149 | } 150 | 151 | function getThemeColors(image) { 152 | let calculatedColor; 153 | const val = $('[%THEMECOLOR%]'); 154 | 155 | if (val.length) { // color hardcoded 156 | var themeRgb = val.match(/\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\)/); 157 | if (themeRgb) { 158 | calculatedColor = rgb(parseInt(themeRgb[1]), parseInt(themeRgb[2]), parseInt(themeRgb[3])); 159 | } else { 160 | calculatedColor = 0xff000000 | parseInt(val, 16); 161 | } 162 | } else { 163 | calculatedColor = getThemeColorsJson(image, 14); 164 | } 165 | if (!isNaN(calculatedColor)) { 166 | let color = new Color(calculatedColor); 167 | while (!pref.darkMode && color.brightness > 200) { 168 | calculatedColor = shadeColor(calculatedColor, 3); 169 | if (settings.showThemeLog) console.log(' >> Shading: ', colToRgb(calculatedColor), ' - brightness: ', color.brightness); 170 | color = new Color(calculatedColor); 171 | } 172 | while (!color.isGreyscale && color.brightness <= 17) { 173 | calculatedColor = tintColor(calculatedColor, 3); 174 | if (settings.showThemeLog) console.log(' >> Tinting: ', colToRgb(calculatedColor), ' - brightness: ', color.brightness); 175 | color = new Color(calculatedColor); 176 | } 177 | const tObj = createThemeColorObject(color) 178 | setTheme(tObj); 179 | } 180 | } 181 | 182 | function createThemeColorObject(color) { 183 | const themeObj = { 184 | primary: color.val, 185 | darkAccent: shadeColor(color.val, 30), 186 | accent: shadeColor(color.val, 15), 187 | lightAccent: tintColor(color.val, 20), 188 | }; 189 | if (color.brightness < 18) { 190 | // hard code these values otherwise darkAccent and accent can be very hard to see on background 191 | themeObj.darkAccent = rgb(32, 32, 32); 192 | themeObj.accent = rgb(56, 56, 56); 193 | themeObj.lightAccent = rgb(78, 78, 78); 194 | } else if (color.brightness < 40) { 195 | themeObj.darkAccent = shadeColor(color.val, 35); 196 | themeObj.accent = tintColor(color.val, 10); 197 | themeObj.lightAccent = tintColor(color.val, 20); 198 | } else if (color.brightness > 210) { 199 | themeObj.darkAccent = shadeColor(color.val, 30); 200 | themeObj.accent = shadeColor(color.val, 20); 201 | themeObj.lightAccent = shadeColor(color.val, 10); 202 | } 203 | return themeObj; 204 | } 205 | 206 | function shadeColor(color, percent) { 207 | const red = getRed(color); 208 | const green = getGreen(color); 209 | const blue = getBlue(color); 210 | 211 | return rgba(darkenColorVal(red, percent), darkenColorVal(green, percent), darkenColorVal(blue, percent), getAlpha(color)); 212 | } 213 | 214 | function tintColor(color, percent) { 215 | const red = getRed(color); 216 | const green = getGreen(color); 217 | const blue = getBlue(color); 218 | 219 | return rgba(lightenColorVal(red, percent), lightenColorVal(green, percent), lightenColorVal(blue, percent), getAlpha(color)); 220 | } 221 | 222 | function darkenColorVal(color, percent) { 223 | const shift = Math.max(color * percent / 100, percent / 2); 224 | const val = Math.round(color - shift); 225 | return Math.max(val, 0); 226 | } 227 | 228 | function lightenColorVal(color, percent) { 229 | const val = Math.round(color + ((255-color) * (percent / 100))); 230 | return Math.min(val, 255); 231 | } 232 | 233 | /** 234 | * Calculates the color "distance" between two colors. Currently uses the redmean 235 | * calculation from https://en.wikipedia.org/wiki/Color_difference. 236 | * The purpose of this method is mostly to determine whether a color drawn next to another color will 237 | * provide enough visual separation. As such, adding some additional weighting based on individual colors differences. 238 | * @param {number} a The first color in numeric form (i.e. rgb(150,250,255)) 239 | * @param {number} b The second color in numeric form (i.e. rgb(150,250,255)) 240 | * @param {boolean=} log Whether to print the distance in the console. Also requires that settings.showThemeLog is true 241 | */ 242 | function colorDistance(a, b, log) { 243 | const aCol = new Color(a); 244 | const bCol = new Color(b); 245 | 246 | const rho = (aCol.r + bCol.r) / 2; 247 | const rDiff = aCol.r - bCol.r; 248 | const gDiff = aCol.g - bCol.g; 249 | const bDiff = aCol.b - bCol.b; 250 | const deltaR = Math.pow(rDiff, 2); 251 | const deltaG = Math.pow(gDiff, 2); 252 | const deltaB = Math.pow(bDiff, 2); 253 | 254 | // const distance = Math.sqrt(2 * deltaR + 4 * deltaG + 3 * deltaB + (rho * (deltaR - deltaB))/256); // old version 255 | let distance = Math.sqrt((2 + rho/256) * deltaR + 4 * deltaG + (2 + (255 - rho)/256) * deltaB); // redmean calculation 256 | if (rDiff >= 50 || gDiff >= 50 || bDiff >= 50) { 257 | // because the colors we are diffing against are usually shades of grey, if one of the colors has a diff of 50 258 | // or more, then it's very likely there will be enough visual separation between the two, so bump up the diff percentage 259 | distance *= 1.1; 260 | } 261 | if (log) { 262 | if (settings.showThemeLog) { 263 | console.log('distance from:', aCol.getRGB(), 'to:', bCol.getRGB(), '=', distance); 264 | } 265 | } 266 | return distance; 267 | } -------------------------------------------------------------------------------- /js/ui-components.js: -------------------------------------------------------------------------------- 1 | class PauseButton { 2 | constructor() { 3 | this.xCenter = 0; 4 | this.yCenter = 0; 5 | this.top = 0; 6 | this.left = 0; 7 | } 8 | 9 | /** 10 | * Set the coordinates of the center point of the pause button 11 | * @param {number} xCenter The x-coordinate of the center of the pause button 12 | * @param {number} yCenter The y-coordinate of the center of the pause button 13 | */ 14 | setCoords(xCenter, yCenter) { 15 | this.xCenter = xCenter; 16 | this.yCenter = yCenter; 17 | this.top = Math.round(this.yCenter - geo.pause_size / 2); 18 | this.left = Math.round(this.xCenter - geo.pause_size / 2); 19 | }; 20 | 21 | draw(gr) { 22 | var pauseBorderWidth = scaleForDisplay(2); 23 | var halfBorderWidth = Math.floor(pauseBorderWidth / 2); 24 | 25 | gr.FillRoundRect(this.left, this.top, geo.pause_size, geo.pause_size, 26 | 0.1 * geo.pause_size, 0.1 * geo.pause_size, rgba(0, 0, 0, 150)); 27 | gr.DrawRoundRect(this.left + halfBorderWidth, this.top + halfBorderWidth, geo.pause_size - pauseBorderWidth, geo.pause_size - pauseBorderWidth, 28 | 0.1 * geo.pause_size, 0.1 * geo.pause_size, pauseBorderWidth, rgba(128, 128, 128, 60)); 29 | gr.FillRoundRect(this.left + 0.26 * geo.pause_size, this.top + 0.25 * geo.pause_size, 30 | 0.12 * geo.pause_size, 0.5 * geo.pause_size, 2, 2, rgba(255, 255, 255, 160)); 31 | gr.FillRoundRect(this.left + 0.62 * geo.pause_size, this.top + 0.25 * geo.pause_size, 32 | 0.12 * geo.pause_size, 0.5 * geo.pause_size, 2, 2, rgba(255, 255, 255, 160)); 33 | }; 34 | 35 | repaint() { 36 | window.RepaintRect(this.left - 1, this.top - 1, geo.pause_size + 2, geo.pause_size + 2); 37 | }; 38 | 39 | mouseInThis(x, y) { 40 | // console.log(x, y, this.top, x >= this.left, y >= this.top, x < this.left + geo.pause_size + 1, y <= this.top + geo.pause_size + 1) 41 | return (x >= this.left && y >= this.top && x < this.left + geo.pause_size + 1 && y <= this.top + geo.pause_size + 1); 42 | }; 43 | } 44 | 45 | class ProgressBar { 46 | /** 47 | * @param {number} ww window width 48 | * @param {number} wh window height 49 | */ 50 | constructor(ww, wh) { 51 | this.x = Math.round(0.025 * ww); 52 | this.y = 0; 53 | this.w = Math.round(0.95 * ww); 54 | this.h = geo.prog_bar_h; 55 | this.progressLength = 0; // fixing jumpiness in progressBar 56 | this.progressMoved = false; // playback position changed, so reset progressLength 57 | this.drag = false; // progress bar is being dragged 58 | this.progressAlphaCol = undefined; 59 | this.lastAccentCol = undefined; 60 | } 61 | 62 | repaint() { 63 | window.RepaintRect(this.x, this.y, this.w, this.h); 64 | } 65 | 66 | setY(y) { 67 | this.y = y; 68 | } 69 | 70 | /** 71 | * @param {GdiGraphics} gr 72 | */ 73 | draw(gr) { 74 | if (pref.show_progress_bar) { 75 | gr.SetSmoothingMode(SmoothingMode.None); // disable smoothing 76 | gr.FillSolidRect(this.x, this.y, this.w, this.h, col.progress_bar); 77 | 78 | if (fb.PlaybackLength) { 79 | let progressStationary = false; 80 | let fillColor = col.primary; 81 | /* in some cases the progress bar would move backwards at the end of a song while buffering/streaming was occurring. 82 | This created strange looking jitter so now the progress bar can only increase unless the user seeked in the track. */ 83 | if (this.progressMoved || Math.floor(this.w * (fb.PlaybackTime / fb.PlaybackLength)) > this.progressLength) { 84 | this.progressLength = Math.floor(this.w * (fb.PlaybackTime / fb.PlaybackLength)); 85 | } else { 86 | progressStationary = true; 87 | } 88 | this.progressMoved = false; 89 | 90 | if (colorDistance(col.primary, col.progress_bar) < 100) { 91 | if (pref.darkMode) { 92 | fillColor = rgb(255,255,255); 93 | } else { 94 | fillColor = col.darkAccent; 95 | } 96 | } 97 | gr.FillSolidRect(this.x, this.y, this.progressLength, this.h, fillColor); 98 | gr.DrawRect(this.x, this.y, this.progressLength, this.h - 1, 1, col.darkAccent); 99 | if (progressStationary && fb.IsPlaying && !fb.IsPaused) { 100 | if (col.accent !== this.lastAccentCol || this.progressAlphaCol === undefined) { 101 | const c = new Color(col.accent); 102 | this.progressAlphaCol = rgba(c.r, c.g, c.b, 128); // fake anti-aliased edge so things look a little smoother 103 | this.lastAccentCol = col.accent; 104 | } 105 | gr.DrawLine(this.progressLength + this.x + 1, this.y, this.progressLength + this.x + 1, this.y + this.h - 1, 1, this.progressAlphaCol); 106 | } 107 | } 108 | } 109 | } 110 | 111 | on_size(windowWidth, windowHeight) { 112 | this.x = windowWidth ? 0.025 * windowWidth : 0; 113 | this.y = 0; 114 | this.w = 0.95 * windowWidth; 115 | this.h = geo.prog_bar_h; 116 | this.progressMoved = true; 117 | } 118 | 119 | on_mouse_lbtn_down(x, y) { 120 | this.drag = true; 121 | } 122 | 123 | on_mouse_lbtn_up(x, y) { 124 | this.drag = false; 125 | if (this.mouseInThis(x, y)) { 126 | this.setPlaybackTime(x); 127 | } 128 | } 129 | 130 | on_mouse_move(x, y) { 131 | if (this.drag) { 132 | this.setPlaybackTime(x); 133 | } 134 | } 135 | 136 | mouseInThis(x, y) { 137 | return (x >= this.x && y >= this.y && x < this.x + this.w && y <= this.y + this.h); 138 | } 139 | 140 | /** @private 141 | * @param {number} x 142 | */ 143 | setPlaybackTime(x) { 144 | let v = (x - this.x) / this.w; 145 | v = (v < 0) ? 0 : (v < 1) ? v : 1; 146 | if (fb.PlaybackTime !== v * fb.PlaybackLength) { 147 | fb.PlaybackTime = v * fb.PlaybackLength; 148 | } 149 | } 150 | } 151 | 152 | class Timeline { 153 | constructor(height) { 154 | this.x = 0; 155 | this.y = 0; 156 | this.w = albumart_size.x - 1; 157 | this.h = height; 158 | 159 | this.playCol = rgba(255, 255, 255, 75); // TODO: remove from theme.js 160 | 161 | /** @private */ this.firstPlayedPercent = 0.33; 162 | /** @private */ this.lastPlayedPercent = 0.66; 163 | /** @private */ this.playedTimesPercents = []; 164 | /** @private */ this.playedTimes = []; 165 | 166 | // recalc'd in setSize 167 | /** @private */ this.lineWidth = is_4k ? 3 : 2; 168 | /** @private */ this.extraLeftSpace = scaleForDisplay(3); // add a little space to the left so songs that were played a long time ago show more in the "added" stage 169 | /** @private */ this.drawWidth = Math.floor(this.w - this.extraLeftSpace - 1 - this.lineWidth / 2); // area that the timeline percents can be drawn in 170 | /** @private */ this.leeway = (1 / this.drawWidth) * (this.lineWidth + scaleForDisplay(2)) / 2; // percent of timeline that we use to determine if mouse is over a playline. Equals half line with + 1 or 2 pixels on either side 171 | 172 | this.tooltipText = ''; 173 | } 174 | 175 | setColors(addedCol, playedCol, unplayedCol) { 176 | this.addedCol = addedCol; 177 | this.playedCol = playedCol; 178 | this.unplayedCol = unplayedCol; 179 | }; 180 | 181 | setPlayTimes(firstPlayed, lastPlayed, playedTimeRatios, playedTimesValues) { 182 | this.firstPlayedPercent = firstPlayed; 183 | this.lastPlayedPercent = lastPlayed; 184 | this.playedTimesPercents = playedTimeRatios; 185 | this.playedTimes = playedTimesValues; 186 | }; 187 | 188 | setSize(x, y, width) { 189 | if (this.x !== x || this.y !== y || this.w !== width) { 190 | this.x = x; 191 | this.y = y; 192 | this.w = width; 193 | 194 | // recalc these values 195 | this.lineWidth = is_4k ? 3 : 2; 196 | this.extraLeftSpace = scaleForDisplay(3); // add a little space to the left so songs that were played a long time ago show more in the "added" stage 197 | this.drawWidth = Math.floor(this.w - this.extraLeftSpace - 1 - this.lineWidth / 2); 198 | this.leeway = (1 / this.drawWidth) * (this.lineWidth + scaleForDisplay(2)) / 2; 199 | } 200 | }; 201 | 202 | setHeight(height) { 203 | this.h = height; 204 | }; 205 | 206 | draw(gr) { 207 | if (this.addedCol && this.playedCol && this.unplayedCol) { 208 | gr.SetSmoothingMode(SmoothingMode.None); // disable smoothing 209 | 210 | gr.FillSolidRect(0, this.y, this.drawWidth + this.extraLeftSpace + this.lineWidth, this.h, this.addedCol); 211 | if (this.firstPlayedPercent >= 0 && this.lastPlayedPercent >= 0) { 212 | const x1 = Math.floor(this.drawWidth * this.firstPlayedPercent) + this.extraLeftSpace; 213 | const x2 = Math.floor(this.drawWidth * this.lastPlayedPercent) + this.extraLeftSpace; 214 | gr.FillSolidRect(x1, this.y, this.drawWidth - x1 + this.extraLeftSpace, this.h, this.playedCol); 215 | gr.FillSolidRect(x2, this.y, this.drawWidth - x2 + this.extraLeftSpace + this.lineWidth, this.h, this.unplayedCol); 216 | } 217 | for (let i = 0; i < this.playedTimesPercents.length; i++) { 218 | const x = Math.floor(this.drawWidth * this.playedTimesPercents[i]) + this.extraLeftSpace; 219 | if (!isNaN(x) && x <= this.w) { 220 | gr.DrawLine(x, this.y, x, this.y + this.h, this.lineWidth, this.playCol); 221 | } else { 222 | // console.log('Played Times Error! ratio: ' + this.playedTimesPercents[i], 'x: ' + x); 223 | } 224 | } 225 | gr.SetSmoothingMode(SmoothingMode.AntiAlias); 226 | } 227 | }; 228 | 229 | mouseInThis(x, y) { 230 | var inTimeline = (x >= this.x && x < this.x + this.w && y >= this.y && y < this.y + this.h); 231 | if (!inTimeline && this.tooltipText.length) { 232 | this.clearTooltip(); 233 | } 234 | return inTimeline; 235 | }; 236 | 237 | on_mouse_move(x, y, m) { 238 | if (pref.show_timeline_tooltips) { 239 | let tooltip = ''; 240 | let percent = toFixed((x + this.x - this.extraLeftSpace) / this.drawWidth, 3); 241 | 242 | // TODO: is this really slow with hundreds of plays? 243 | for (var i = 0; i < this.playedTimesPercents.length; i++) { 244 | if (percent >= this.playedTimesPercents[i] - this.leeway && percent < this.playedTimesPercents[i] + this.leeway) { 245 | var date = new Date(this.playedTimes[i]); 246 | if (tooltip.length) { 247 | tooltip += '\n'; 248 | } 249 | tooltip += date.toLocaleString(); 250 | } 251 | else if (percent < this.playedTimesPercents[i]) { 252 | // the list is sorted so we can abort early 253 | if (!tooltip.length) { 254 | if (i === 0) { 255 | const added = dateDiff($date('[%added%]'), this.playedTimes[0]); 256 | tooltip = added ? `First played after ${added}` : ''; 257 | } else { 258 | tooltip = 'No plays for ' + dateDiff(new Date(this.playedTimes[i - 1]).toISOString(), this.playedTimes[i]); 259 | } 260 | } 261 | break; 262 | } 263 | } 264 | if (tooltip.length) { 265 | this.tooltipText = tooltip; 266 | tt.showImmediate(this.tooltipText); 267 | } else { 268 | this.clearTooltip(); 269 | } 270 | } 271 | }; 272 | 273 | clearTooltip() { 274 | this.tooltipText = ''; 275 | tt.stop(); 276 | }; 277 | } 278 | -------------------------------------------------------------------------------- /js/volume.js: -------------------------------------------------------------------------------- 1 | class Volume { 2 | constructor (x, y, w, h) { 3 | this.x = x; 4 | this.y = y; 5 | this.w = w; 6 | this.h = h; 7 | this.mx = 0; 8 | this.my = 0; 9 | this.clickX = 0; 10 | this.clickY = 0; 11 | this.drag = false; 12 | this.drag_vol = 0; 13 | this.tt = new TooltipHandler(); 14 | } 15 | 16 | /** 17 | * Determines if a point is "inside" the bounds of the volume control. 18 | * @param {number} x 19 | * @param {number} y 20 | */ 21 | trace(x, y) { 22 | // const margin = this.drag ? 200 : 0; // the area the mouse can go outside physical bounds of the volume control 23 | const margin = 0; // the area the mouse can go outside physical bounds of the volume control 24 | return x > this.x - margin && 25 | x < this.x + this.w + margin && 26 | y > this.y - margin && 27 | y < this.y + this.h + margin; 28 | } 29 | 30 | /** 31 | * @param {number} scrollAmt 32 | */ 33 | wheel(scrollAmt) { 34 | if (!this.trace(this.mx, this.my)) { 35 | return false; 36 | } 37 | 38 | scrollAmt > 0 ? fb.VolumeUp() : fb.VolumeDown(); 39 | 40 | return true; 41 | } 42 | 43 | 44 | move(x, y) { 45 | this.mx = x; 46 | this.my = y; 47 | 48 | if (this.clickX && this.clickY && (this.clickX !== x || this.clickY !== y)) { 49 | this.drag = true; 50 | } 51 | 52 | if (this.trace(x, y) || this.drag) { 53 | if (this.drag) { 54 | y -= this.y; 55 | const maxAreaExtraHeight = 5; // give a little bigger target area to select -0.00dB 56 | const pos = (y < maxAreaExtraHeight) ? 57 | 1 : 58 | (y > this.h) ? 59 | 0 : 60 | 1 - (y - maxAreaExtraHeight) / (this.h - maxAreaExtraHeight); 61 | this.drag_vol = _.toDb(pos); 62 | fb.Volume = this.drag_vol; 63 | } 64 | 65 | return true; 66 | 67 | } else { 68 | this.drag = false; 69 | 70 | return false; 71 | } 72 | } 73 | 74 | lbtn_down(x, y) { 75 | if (this.trace(x, y)) { 76 | this.clickX = x; 77 | this.clickY = y; 78 | this.move(x, y); // force volume to update without needing to move or release lbtn 79 | return true; 80 | } else { 81 | return false; 82 | } 83 | } 84 | 85 | lbtn_up(x, y) { 86 | this.clickX = 0; 87 | this.clickY = 0; 88 | if (this.drag) { 89 | this.drag = false; 90 | return true; 91 | } 92 | const inVolumeSlider = this.trace(x,y); 93 | if (inVolumeSlider) { 94 | // we had not started a drag 95 | this.drag = true; 96 | this.move(x,y); // adjust volume 97 | this.drag = false; 98 | } 99 | return inVolumeSlider; 100 | } 101 | 102 | leave() { 103 | this.drag = false; 104 | } 105 | 106 | /** 107 | * Returns the size in pixels of the fill portion of the volume bar, based on current volume 108 | * @param {string} type Either 'h' or 'w' for vertical or horizontal volume bars 109 | */ 110 | fillSize(type) { 111 | return Math.ceil((type === "h" ? this.h : this.w) * (Math.pow(10, fb.Volume / 50) - 0.01) / 0.99); 112 | } 113 | } 114 | 115 | class VolumeBtn { 116 | constructor() { 117 | this.x = 0; 118 | this.y = 0; 119 | this.w = scaleForDisplay(28); 120 | this.h = scaleForDisplay(180); 121 | 122 | this.inThisPadding = Math.min(this.w / 2); 123 | this.volTextW = scaleForDisplay(150); 124 | this.volTextH = scaleForDisplay(30); 125 | 126 | 127 | // Runtime state 128 | this.mouse_in_panel = false; 129 | this.show_volume_bar = false; 130 | 131 | // Objects 132 | /** @type {Volume} */ 133 | this.volume_bar = undefined; 134 | } 135 | 136 | /** 137 | * @param {GdiGraphics} gr 138 | */ 139 | on_paint(gr) { 140 | if (this.show_volume_bar) { 141 | const x = this.x, 142 | y = this.y, 143 | w = this.w, 144 | h = this.h; 145 | 146 | const fillHeight = this.volume_bar.fillSize('h'); 147 | const lineThickness = scaleForDisplay(1); 148 | 149 | let fillColor = col.primary; 150 | gr.FillSolidRect(x, y, w, h, col.bg); 151 | if (colorDistance(col.primary, col.progress_bar) < 105 && pref.darkMode) { 152 | fillColor = rgb(255,255,255); 153 | } else if (colorDistance(col.primary, col.bg) < 105) { 154 | fillColor = col.darkAccent; 155 | } 156 | gr.FillSolidRect(x, y + h - fillHeight, w, fillHeight, fillColor); 157 | gr.DrawRect(x, y, w, h - lineThickness, lineThickness, col.progress_bar); 158 | const volume = fb.Volume.toFixed(2) + ' dB'; 159 | const volFont = ft.album_sml; 160 | const volMeasurements = gr.MeasureString(volume, volFont, 0, 0, 0, 0); 161 | const volHeight = volMeasurements.Height; 162 | const volWidth = volMeasurements.Width + 1; 163 | const border = scaleForDisplay(3); 164 | let txtY = y; 165 | if (transport.displayBelowArtwork) { 166 | txtY = this.y - this.h - this.volTextH - scaleForDisplay(2); 167 | } 168 | gr.FillSolidRect(x - border, txtY + h, volWidth + border * 2, volHeight + border, rgba(0, 0, 0, 128)); 169 | gr.DrawString(volume, volFont, rgb(0,0,0), x - 1, txtY - 1 + h, this.volTextW, this.volTextH); 170 | gr.DrawString(volume, volFont, rgb(0,0,0), x - 1, txtY + 1 + h, this.volTextW, this.volTextH); 171 | gr.DrawString(volume, volFont, rgb(0,0,0), x + 1, txtY - 1 + h, this.volTextW, this.volTextH); 172 | gr.DrawString(volume, volFont, rgb(0,0,0), x + 1, txtY + 1 + h, this.volTextW, this.volTextH); 173 | gr.DrawString(volume, volFont, rgb(255,255,255), x, txtY + h, this.volTextW, this.volTextH); 174 | } 175 | } 176 | 177 | repaint() { 178 | const xyPadding = scaleForDisplay(3), whPadding = xyPadding * 2; 179 | window.RepaintRect(this.x - xyPadding, this.volume_bar.y - xyPadding, this.volume_bar.w + whPadding, this.volume_bar.h + whPadding); 180 | 181 | let txtY = this.y + this.h; 182 | if (transport.displayBelowArtwork) { 183 | txtY = this.y - this.volTextH; 184 | } 185 | window.RepaintRect(this.x - xyPadding, txtY, this.volTextW + xyPadding, this.volTextH + xyPadding); 186 | } 187 | 188 | setPosition(x, y, btnWidth) { 189 | const wh = window.Height; 190 | this.w = btnWidth - 2; 191 | const center = Math.floor(this.w / 2); 192 | 193 | this.x = x; 194 | if (transport.displayBelowArtwork) { 195 | this.y = y + center - this.h; 196 | } else { 197 | this.y = y + center + scaleForDisplay(3); 198 | } 199 | this.volume_bar = new Volume(this.x, this.y, this.w, Math.min(wh - this.y - 4, this.h)); 200 | } 201 | 202 | on_mouse_move(x, y, m) { 203 | qwr_utils.DisableSizing(m); 204 | 205 | if (this.volume_bar.drag) { 206 | this.volume_bar.move(x, y); 207 | return; 208 | } 209 | 210 | if (this.show_volume_bar && this.volume_bar.trace(x, y)) { 211 | this.mouse_in_panel = true; 212 | } else { 213 | this.mouse_in_panel = false; 214 | } 215 | 216 | if (this.show_volume_bar) { 217 | if (this.mouseInThis(x, y)) { 218 | this.volume_bar.move(x, y); 219 | } else { 220 | this.showVolumeBar(false); 221 | this.repaint(); 222 | } 223 | } 224 | } 225 | 226 | mouseInThis(x, y) { 227 | const padding = this.inThisPadding; 228 | if (x > this.x - padding && 229 | x <= this.x + this.w + padding && 230 | y > this.y - this.w && // allow entire button height to be considered 231 | y <= this.y + this.h + padding) { 232 | return true; 233 | } 234 | return false; 235 | } 236 | 237 | on_mouse_lbtn_down(x, y, m) { 238 | if (this.show_volume_bar) { 239 | const val = this.volume_bar.lbtn_down(x, y); 240 | return val; 241 | } 242 | return false; 243 | } 244 | 245 | on_mouse_lbtn_up(x, y, m) { 246 | qwr_utils.EnableSizing(m); 247 | 248 | if (this.show_volume_bar) { 249 | return this.volume_bar.lbtn_up(x, y); 250 | } 251 | } 252 | 253 | on_mouse_wheel(delta) { 254 | if (this.mouse_in_panel) { 255 | if (!this.show_volume_bar || !this.volume_bar.wheel(delta)) { 256 | if (delta > 0) { 257 | fb.VolumeUp(); 258 | } 259 | else { 260 | fb.VolumeDown(); 261 | } 262 | } 263 | return true; 264 | } 265 | return false; 266 | } 267 | 268 | on_mouse_leave() { 269 | if (!this.volume_bar || this.volume_bar.drag) { 270 | return; 271 | } 272 | 273 | this.mouse_in_panel = false; 274 | 275 | if (this.show_volume_bar) { 276 | this.showVolumeBar(false); 277 | this.repaint(); 278 | } 279 | this.volume_bar.leave(); 280 | } 281 | 282 | on_volume_change(val) { 283 | if (this.show_volume_bar) { 284 | this.repaint(); 285 | } 286 | } 287 | 288 | /** 289 | * Show the Volume Bar 290 | * @param {boolean} show 291 | */ 292 | showVolumeBar(show) { 293 | this.show_volume_bar = show; 294 | this.repaint(); 295 | if (show) { 296 | this.volume_bar.tt.stop(); 297 | } 298 | } 299 | 300 | /** 301 | * Toggles volume bar on/off 302 | */ 303 | toggleVolumeBar() { 304 | this.showVolumeBar(!this.show_volume_bar); 305 | } 306 | } -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | ### My unprioritized todo list 2 | 3 | - Prevent duplicate label imgs from showing 4 | - Handle GIFs for image types? 5 | - Color picker menu with meta_db saving of seleted color based on arist/album/disc 6 | - Better resolution handling for intermediate sizes (scaling? DPI based?) 7 | - Better handling of FLAC codec information in metadata panel. Should be similar to what shows in playlist. 8 | - Allow some default theme colors to be specified in configuration file (will need to check for progress fill/volume fill issues) 9 | - Redo button code to something a little more sane 10 | - Add right click copy/paste on library search box 11 | - Lyrics long-press menu with Enable/Disable, Edit, maybe adjust timestamps for lrcs? 12 | - Auto downloading of cdArt? 13 | - Playlist View settings should be moved to config file, creating an object with name, filter, and optional custom sort parameter See [here](https://github.com/kbuffington/Georgia/issues/85). 14 | - Playlist check cached artwork from other art cache? 15 | - Add ability to manually cycle through artwork using mouse wheel. Would disable art cycling. 16 | - Add option to prefer folder art over embedded 17 | 18 | ### Items completed 19 | 20 | - Convert to using foo_spider_monkey_panel instead of foo_jscript (implemented in 2.0.0) 21 | - Better lyrics handling (implemented in 2.0.0) 22 | - Move progress bar code to ui-components and turn it into a class (implemented in 2.0.0) 23 | - Investigate using a config.json file to control grid data, codec information, etc. (implemented in 2.0.0) 24 | - Simplify handling of labels in playlist (actually use meta values instead of splitting on ',') (implemented in 2.0.0) 25 | - Add option to draw labels directly on background (implemented in 2.0.0) 26 | - Add on_playback_dynamic_info_track updates for streams (implemented in 2.0.0) 27 | - Rewrite Library search/selection code to improve contrast on partial selected text (implemented in 2.0.3) 28 | --------------------------------------------------------------------------------