├── .gitattributes ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app.js ├── clink.wav ├── icon.svg ├── index.html ├── lib └── fitty.module.js ├── manifest.webmanifest ├── movies.txt ├── screenshot.png ├── test-subtitles.txt └── tick.wav /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # ========================= 3 | # Operating System Files 4 | # ========================= 5 | 6 | # OSX 7 | # ========================= 8 | 9 | .DS_Store 10 | .AppleDouble 11 | .LSOverride 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear on external disk 17 | .Spotlight-V100 18 | .Trashes 19 | 20 | # Directories potentially created on remote AFP share 21 | .AppleDB 22 | .AppleDesktop 23 | Network Trash Folder 24 | Temporary Items 25 | .apdisk 26 | 27 | # Windows 28 | # ========================= 29 | 30 | # Windows image file caches 31 | Thumbs.db 32 | ehthumbs.db 33 | 34 | # Folder config file 35 | Desktop.ini 36 | 37 | # Recycle Bin used on file shares 38 | $RECYCLE.BIN/ 39 | 40 | # Windows Installer files 41 | *.cab 42 | *.msi 43 | *.msm 44 | *.msp 45 | 46 | # Windows shortcuts 47 | *.lnk 48 | 49 | # Linux 50 | # ========================= 51 | *~ 52 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Aabroo", 4 | "Aadi", 5 | "ACAB", 6 | "Alethiometer", 7 | "Ángel", 8 | "Baggins", 9 | "Barnstar", 10 | "bbox", 11 | "boiteux", 12 | "Cinemascape", 13 | "Deta", 14 | "diable", 15 | "Exponát", 16 | "Fanaa", 17 | "fitties", 18 | "fitty", 19 | "Foodfight", 20 | "fullscreen", 21 | "gioventù", 22 | "haha", 23 | "IIIIX", 24 | "IIIX", 25 | "initialise", 26 | "Jagadamba", 27 | "Khushiyaan", 28 | "Kickboxer", 29 | "Kuni", 30 | "meglio", 31 | "Misérables", 32 | "Moviescape", 33 | "Movieverysingleone", 34 | "Movieverywhere", 35 | "Mulgi", 36 | "Napoléon", 37 | "Noëlle", 38 | "parentheticals", 39 | "plinketto", 40 | "Plinko", 41 | "Polidoro", 42 | "prestyling", 43 | "pushback", 44 | "rafid", 45 | "Randoreel", 46 | "Reely", 47 | "Rocketman", 48 | "Royale", 49 | "Satyricon", 50 | "Schennink", 51 | "Spinema", 52 | "Uchōten", 53 | "VIIII", 54 | "webserver", 55 | "Witchboard", 56 | "Zatōichi" 57 | ] 58 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [True Random Movie][app] 2 | 3 | Play movie reel roulette with over 32 thousand titles. 4 | 5 | Find movies that you would never watch otherwise. 6 | 7 | ## [▶ Launch the web app and 💫 Spin away! ◀][app] 8 | 9 | [![](screenshot.png)][app] 10 | 11 | ## Features 12 | 13 | - Data set of over thirty two thousand movie titles 14 | - List wraps seamlessly if you can manage to get to the end of it 15 | - Good spinner physics, where the pegs affect the ticker and the ticker affects the pegs, and the pegs 16 | - Mobile friendly 17 | - Browser history integration - the movie you spin to is included in the URL so you can go back with your browser's back button 18 | - "Watch Online" link to quickly do a web search for a movie (often you can find a site to it for free easily) 19 | - Ctrl+F to filter the films list by title 20 | - Handles accents (e vs. é) and stylization variations like "2" vs. "two" vs. "II" 21 | - Plinko-like bonus round for picking between movies that have the same name (usually different dates) 22 | 23 | ## Data Sources 24 | 25 | ### Wikipedia 26 | I copied from [Wikipedia's alphabetical lists of movies](https://en.wikipedia.org/wiki/Lists_of_films#Alphabetical_indices). 27 | I made many edits to normalize the data, and then contributed back to wikipedia, for which someone awarded me The Copyeditor's Barnstar 😊 28 | 29 | ![](https://upload.wikimedia.org/wikipedia/commons/thumb/e/ee/Copyeditor_Barnstar_Hires.png/100px-Copyeditor_Barnstar_Hires.png) 30 | 31 | ## Development Setup 32 | 33 | - [Clone the repo.][git clone] 34 | - Open up a command prompt / terminal in the project directory. 35 | - Start a webserver, such as [live-server][] (`npx live-server` if you have Node.js) 36 | 37 | ## Alternative Names 38 | 39 | - Unseen Cinema 40 | - True Random Movie - relaying relative randomness 41 | - Mega Movie Spinner 42 | - The Watch Whatever Wheel 43 | - Cinema Spin'em'up 44 | - Hipster Film Wheel 45 | - FlickPick 46 | - QuickFlickPick 47 | - McQuickFlickPick 48 | - QuickFlickPicky McQuickFlickPickFace 49 | - Cinemascape / Moviescape - it can give you an overview of the landscape of film, but only thru titles and watching them 50 | - Reely Random - relaying relative randomness 51 | - Vast Cast Spinner - doesn't relate to cinema, but that could be a positive if I make it more general, let you paste in custom lists etc. 52 | - Spinema 53 | - Random All Movies 54 | - Randy's Choice Movies or whatever 55 | - Wheel of Film 56 | - Wheel of Film-Turn - awkward punning 57 | - Reel of Fortune 58 | - Reel Wheel 59 | - Randoreel 60 | - FlickSelect 61 | - Reely Random - relaying relative randomness 62 | - Spin the Reel 63 | - CinemaSampler - sounds more fitting for movie collage generator 64 | - The Ultimate Film Picker of Eternity 65 | - CinemaSelect (sounds like a terrible "club" / movie rewards card) 66 | - Capricious Celluloid 67 | - Strange Movie Picker 68 | - Unusual Film Finder 69 | - Box of Chocolates - unclear 70 | - Film Alethiometer - obscure reference 71 | - SpinCine - how to pronounce? 72 | - MovieMovieMovieMovieMovie / FilmFilmFilmFilmFilmFilmFilm 73 | - Movieverywhere / Movieverysingleone 74 | - Bursting at the Scenes wif mooVVVVVV 4 u 75 | - (could maybe do something like "I Don't Want To Think About What To Watch", some sort of sentence or expression of the scenario you'd want to use it) 76 | 77 | building blocks: 78 | - Film / Flick / Movie / Reel / Cinema / Watch / Silver Screen 79 | - Random / Generate / Pick / Select / Choose / Any / All / Every / Spinner / Wheel / Dice 80 | 81 | 82 | [git clone]: https://help.github.com/articles/cloning-a-repository/ 83 | [live-server]: https://www.npmjs.com/package/live-server 84 | [app]: https://1j01.github.io/true-random-movie/ 85 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import fitty from "./lib/fitty.module.js"; 2 | 3 | const title_output = document.getElementById("title-output"); 4 | const instance_output = document.getElementById("instance-output"); 5 | const watch_online_link = document.getElementById("watch-online-link"); 6 | const copy_to_clipboard_button = document.getElementById("copy-to-clipboard") 7 | const result_container = document.getElementById("result"); 8 | const go_button = document.getElementById("go"); 9 | const mega_spinner_container = document.getElementById("mega-spinner"); 10 | const mega_spinner_svg = document.getElementById("mega-spinner-svg"); 11 | const mega_spinner_ticker = document.getElementById("mega-spinner-ticker"); 12 | const mega_spinner_items = document.getElementById("mega-spinner-items"); 13 | const plinketto_container = document.getElementById("plinketto"); 14 | const plinketto_svg = document.getElementById("plinketto-svg"); 15 | const filters = document.getElementById("filters"); 16 | const open_filters_button_link = document.getElementById("open-filters"); 17 | const close_filters_button = document.getElementById("close-filters"); 18 | const title_filter = document.getElementById("title-filter"); 19 | 20 | const audio_context = new AudioContext(); 21 | 22 | const load_sound = async (path) => { 23 | const response = await fetch(path); 24 | if (response.ok) { 25 | return await audio_context.decodeAudioData(await response.arrayBuffer()); 26 | } else { 27 | throw new Error(`got HTTP ${response.status} fetching '${path}'`); 28 | } 29 | }; 30 | 31 | let tick_sound; 32 | let plink_sound; 33 | 34 | function mod(n, m) { 35 | return ((n % m) + m) % m; 36 | } 37 | /** 38 | * Returns a pseudo-random number generator function that returns 0 to 1 like Math.random() 39 | */ 40 | function sfc32(a, b, c, d) { 41 | return function () { 42 | a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0; 43 | var t = (a + b) | 0; 44 | a = b ^ b >>> 9; 45 | b = c + (c << 3) | 0; 46 | c = (c << 21 | c >>> 11); 47 | d = d + 1 | 0; 48 | t = t + d | 0; 49 | c = c + t | 0; 50 | return (t >>> 0) / 4294967296; 51 | } 52 | } 53 | /** 54 | * Shuffles array in place. ES6 version 55 | * @param {Array} a items An array containing the items. 56 | */ 57 | function shuffle(a, random = Math.random) { 58 | for (let i = a.length - 1; i > 0; i--) { 59 | const j = Math.floor(random() * (i + 1)); 60 | [a[i], a[j]] = [a[j], a[i]]; 61 | } 62 | return a; 63 | } 64 | 65 | function parse_title_line(title_line) { 66 | // parse e.g. 67 | // "Witch Hunt (1994, 1999 TV & 2019)" 68 | // "Witchboard 2: The Devil's Doorway (1993)" 69 | // (colon is not separator unless at end of title) 70 | // "The Witness (1969 French, 1969 Hungarian, 1992 short, 2000, 2012, 2015 American & 2015 Chinese)" 71 | // "The Wolf Man (1924 short, 1941)" 72 | // no & 73 | // "Fury (1936 & 2012 & 2014)" 74 | // no , 75 | // "Kin Fables (2013-2015 Canadian film project including three short films)" 76 | // (edited to move description into parentheses) 77 | // "The Best of Youth (La Meglio gioventù) (2003)" 78 | // parentheses showing original title 79 | // "Everything You Always Wanted to Know About Sex* (*But Were Afraid to Ask) (1972)" 80 | // parentheses in title 81 | // "(500) Days of Summer (2009)" 82 | // parentheses in title which is just a number 83 | // "(Untitled) (2009)" 84 | // (entirely parenthesized title) 85 | // "The Fabulous Journey of Mr. Bilbo Baggins, The Hobbit, Across the Wild Land, Through the Dark Forest, Beyond the Misty Mountains. There and Back Again (1985)" 86 | // really long... 87 | 88 | // don't need to parse, because not included in movies.txt (either cleaned up, missed, or deleted): 89 | // "Witch Hunt: (1994, 1999 TV & 2019)" (colon before parenthetical) 90 | // "Act of Violence (1949, 1956, & 1959)" (Oxford comma) 91 | // "Aabroo, 1943 & 1968" (no parenthetical) 92 | // "9: (2005 short) & (2009)" (& outside multiple date parentheticals) 93 | // "Beasties (1985) (1989)" (multiple date parentheticals) 94 | // "The Betrayed (1993) * (2008)" (typo'd & outside multiple date parentheticals) 95 | // "Calendar Girls (2015 film) (2015)" (multiple date parentheticals) 96 | // "Dance of the Dead (2007 film) (2008)" (haha) 97 | // "The Hobbit (1977 & The Hobbit (1985 film))" (extra end paren) 98 | // "Aadi (2002 & 2005" (missing end paren) 99 | // "The Bridge of San Luis Rey]]: (1929, 1944 & 2004)" (typo'd wikipedia syntax) 100 | // "Die Hard with a Vengeance (1998]" (typo'd wikipedia syntax) 101 | // "God's Club (2015)[1]" (wikipedia reference) 102 | // "Accidentally Engaged" (no parenthetical or dates) 103 | // "Alienator (1989) (TV)" (non-separating indication in separate parenthetical) 104 | // "Mermaid's Scar (1993) (OVA)" (non-separating indication in separate parenthetical) 105 | // "Five Children and It (film) (2004)" (useless non-separating indication in separate parenthetical) 106 | // "Lincoln: Trial by Fire (TV, 1974)" (non-separating indication separated by comma) 107 | // "Satyricon (Polidoro, 1969)" (non-separating indication separated by comma) 108 | // "Death on the Nile (1978 and 2004 television movie)" (and instead of &) 109 | // "Le Diable boiteux (1948; tr. The Lame Devil) (translated title in date parenthetical) 110 | // "Exponát roku 1827 (2008) Czech" (non-separating indication after parenthetical) 111 | // "Nation and Destiny series (1992: 2002)" (I think this one's my fault) 112 | // "Jagadamba (TBD)" (no date) 113 | // "Kuni Mulgi Deta Ka Mulgi (TBA)" (no date) 114 | // "Foodfight! (unreleased)" (no date) 115 | // "Grass Roots (production)" (no date) 116 | // "Khushiyaan (?)" (no date) 117 | 118 | var open_paren_index = title_line.lastIndexOf("("); 119 | if (open_paren_index === -1) { 120 | return; 121 | } 122 | var title = title_line.slice(0, open_paren_index); 123 | var parenthetical = title_line.slice(open_paren_index + 1, -1); 124 | 125 | title = title.trim(); 126 | 127 | const instances = parenthetical.split(/[,&]\s*/g).map((str) => str.trim()); 128 | 129 | return { title, parenthetical, instances }; 130 | } 131 | 132 | let displayed_title; 133 | 134 | const display_result = (title, instance_text) => { 135 | title_output.innerHTML = ""; 136 | let heading_level = 2; 137 | const title_parts = title.split(/:\s/g); 138 | for (let title_part_index = 0; title_part_index < title_parts.length; title_part_index++) { 139 | const title_part = title_parts[title_part_index]; 140 | const scale_wrapper = document.createElement("div"); 141 | const heading = document.createElement(`h${heading_level}`); 142 | const small_start_match = title_part.match(/^((?:in |on |and | with )(?:the )?|(?:the movie\b))(.*)/i); 143 | const prevent_small_start_match = title_part.match(/In The Lair|In the Beginning|In Your|In It To|On His Own/i); 144 | let remaining_title_part = title_part; 145 | if (small_start_match && title_part_index > 0 && !prevent_small_start_match) { 146 | const small_span = document.createElement("span"); 147 | small_span.style.fontSize = "0.5em"; 148 | small_span.textContent = small_start_match[1]; 149 | heading.append(small_span); 150 | remaining_title_part = small_start_match[2]; 151 | } 152 | const small_mid_match = remaining_title_part.match(/(.*)(\svs?\.?\s)(.*)/i); 153 | if (small_mid_match) { 154 | heading.append(document.createTextNode(small_mid_match[1])); 155 | const small_span = document.createElement("span"); 156 | small_span.style.fontSize = "0.7em"; 157 | small_span.textContent = small_mid_match[2]; 158 | heading.append(small_span); 159 | remaining_title_part = small_mid_match[3]; 160 | } 161 | heading.append(document.createTextNode(remaining_title_part)); 162 | heading.classList.add("scale-to-fit-width"); 163 | scale_wrapper.append(heading); 164 | title_output.append(scale_wrapper); 165 | heading_level += 1; 166 | } 167 | 168 | instance_output.textContent = `(${instance_text})`; 169 | 170 | watch_online_link.href = `https://duckduckgo.com/?q=${encodeURIComponent( 171 | `${title} (${instance_text.replace(/\sTV$/, "")}) (watch online)` 172 | )}`; 173 | 174 | copy_to_clipboard_button.onclick = async () => { 175 | const toast = document.createElement("div"); 176 | toast.setAttribute("role", "alert"); 177 | toast.className = "clipboard-toast"; 178 | try { 179 | await navigator.clipboard.writeText(`${title} (${instance_text.replace(/\sTV$/, "")})`); 180 | toast.textContent = "Copied!"; 181 | } catch (error) { 182 | console.error("Failed to copy to clipboard:", error); 183 | toast.classList.add("error"); 184 | toast.textContent = "Couldn't copy"; 185 | } 186 | const rect = copy_to_clipboard_button.getBoundingClientRect(); 187 | toast.style.position = "fixed"; 188 | toast.style.left = rect.left + "px"; 189 | toast.style.top = rect.top - 30 + "px"; 190 | document.body.append(toast); 191 | setTimeout(() => { 192 | toast.style.opacity = 0; 193 | toast.style.transform = "translateY(-8px)"; 194 | // remove some time after exit transition 195 | // (don't need to bother with transitionend event) 196 | setTimeout(() => { 197 | toast.remove(); 198 | }, 1000); 199 | }, 1000); 200 | }; 201 | 202 | result_container.hidden = false; 203 | result_container.style.transition = "unset"; 204 | setTimeout(() => { 205 | result_container.style.opacity = 1; 206 | setTimeout(() => { 207 | result_container.style.transition = ""; 208 | }, 15); 209 | }); 210 | 211 | const headings = [...document.querySelectorAll(".scale-to-fit-width")]; 212 | headings[headings.length - 1].addEventListener("fit", () => { 213 | // fitty handles scaling individual headings with a max size, 214 | // but I want to scale things down in proportion to each other in some cases 215 | 216 | // Prevent subtitles from being larger than main title 217 | // (Note: sometimes this isn't really good, where the first title isn't the most important part) 218 | // const mainTitleFontSize = parseFloat(headings[0].style.fontSize); 219 | // headings.forEach((heading) => { 220 | // if (heading !== headings[0] && parseFloat(heading.style.fontSize) >= mainTitleFontSize) { 221 | // heading.style.fontSize = `${mainTitleFontSize * 0.8}px`; 222 | // } 223 | // }); 224 | // Limit overall font size, scaling things down proportionally 225 | const maxFontSize = 100; 226 | if (headings.some((heading) => parseFloat(heading.style.fontSize) > maxFontSize)) { 227 | const largest = headings.reduce((prevMax, heading) => Math.max(prevMax, parseFloat(heading.style.fontSize)), 0); 228 | headings.forEach((heading) => { 229 | heading.style.fontSize = `${parseFloat(heading.style.fontSize) / largest * maxFontSize}px` 230 | }); 231 | } 232 | }); 233 | fitty(".scale-to-fit-width", { maxSize: 200 }); 234 | fitty.fitAllImmediately(); 235 | // TODO: make sure fitty gets cleaned up 236 | 237 | displayed_title = title; 238 | }; 239 | 240 | const clear_result = () => { 241 | result_container.hidden = true; 242 | result_container.style.opacity = 1; 243 | displayed_title = null; 244 | }; 245 | 246 | const picked_title_line = (title_line) => { 247 | const { title, instances } = parse_title_line(title_line); 248 | if (instances.length > 1) { 249 | setup_plinketto(instances); 250 | plinketto_container.hidden = false; 251 | animate("plinketto"); 252 | } else { 253 | location.hash = `${title} (${instances[0]})`; 254 | } 255 | // displayed_title is currently used for logic, so display this early, behind plinketto 256 | display_result(title, instances[0]); 257 | }; 258 | 259 | const quick_pick = (title_line) => { 260 | if (!title_line) { 261 | const title_line_index = ~~(Math.random() * title_line_indexes.length); 262 | title_line = unfiltered_title_lines[title_line_index]; 263 | } 264 | const { title, instances } = parse_title_line(title_line); 265 | const instance_index = ~~(Math.random() * instances.length); 266 | const instance_text = instances[instance_index]; 267 | display_result(title, instance_text); 268 | // location.hash = `${title} (${instance_text.replace(/\sTV$/, "")})`; 269 | location.hash = `${title} (${instance_text})`; 270 | }; 271 | 272 | fitty("#go", { maxSize: 30 }); 273 | 274 | let unfiltered_title_lines; 275 | let normalized_unfiltered_title_lines; // for loose string comparison 276 | let title_line_indexes; // can be a sorted/shuffled/filtered/wrapped list of indexes into unfiltered_title_lines 277 | let shuffled_unfiltered_title_line_indexes; // for restoring from filtering 278 | 279 | // TODO: use pool of elements to avoid garbage collection churn? 280 | let mega_spinner_animating = false; 281 | let dragging = false; 282 | let peg_hit_timer = 0; 283 | let item_els = []; 284 | let spin_position = 0; 285 | let spin_velocity = 0; 286 | let ticker_index_attachment = 0; 287 | let ticker_rotation_deg = 0; 288 | // let ticker_rotation_speed_deg_per_frame = 0; 289 | const render_mega_spinner = () => { 290 | // Fix for mobile chrome (not in Desktop Site mode, and either: 1. not after a refresh or 2. after a refresh and then rotating the phone) 291 | // I think it relates to the top bar UI that can hide/show when scrolling a page. 292 | mega_spinner_container.style.top = `${(mega_spinner_container.clientHeight - mega_spinner_svg.getBoundingClientRect().height) / 2}px`; 293 | 294 | const item_height = parseFloat(getComputedStyle(mega_spinner_items).getPropertyValue("--item-height")); 295 | const visible_range = Math.ceil(mega_spinner_container.clientHeight / item_height); 296 | const min_visible_index = Math.floor(spin_position - visible_range / 2); 297 | const max_visible_index = Math.ceil(spin_position + visible_range / 2 + 1); 298 | 299 | let item_el_index = 0; 300 | let item_el = item_els[item_el_index]; 301 | const new_item_els_list = []; 302 | let to_remove_item_els = []; 303 | for (let i = min_visible_index; i < max_visible_index; i += 1) { 304 | // there's gotta be a better way to name these things 305 | const title_line_index_index = mod(i, title_line_indexes.length); 306 | const title_line_index = title_line_indexes[title_line_index_index]; 307 | const title_line = unfiltered_title_lines[title_line_index]; 308 | // let y = mod(spin_position - i, title_line_indexes.length); 309 | let y = spin_position - i; 310 | if (y > visible_range) { 311 | y -= title_line_indexes.length; 312 | } 313 | item_el = item_els[item_el_index]; 314 | if (item_el && item_el.title_line_index_index === title_line_index_index) { 315 | new_item_els_list.push(item_el); 316 | } else if (item_el) { 317 | to_remove_item_els.push(item_el); 318 | item_el = null; 319 | } 320 | 321 | if (!item_el) { 322 | item_el = document.createElementNS("http://www.w3.org/2000/svg", "g"); 323 | item_el.setAttribute("class", "mega-spinner-item"); 324 | const rect_el = document.createElementNS("http://www.w3.org/2000/svg", "rect"); 325 | rect_el.setAttribute("fill", `hsl(${title_line_index / unfiltered_title_lines.length}turn, 80%, 50%)`); 326 | rect_el.setAttribute("x", 0); 327 | rect_el.setAttribute("y", 0); 328 | rect_el.setAttribute("width", "100%"); 329 | rect_el.setAttribute("height", item_height); 330 | const text_el = document.createElementNS("http://www.w3.org/2000/svg", "text"); 331 | text_el.setAttribute("dominant-baseline", "middle"); 332 | text_el.setAttribute("x", 15); 333 | text_el.setAttribute("y", item_height / 2); 334 | text_el.textContent = title_line.replace(/([!?.,]):/g, "$1"); 335 | item_el.title_line_index_index = title_line_index_index; 336 | const peg_el = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 337 | peg_el.setAttribute("cx", peg_size_px / 2); 338 | peg_el.setAttribute("cy", item_height); 339 | peg_el.setAttribute("r", peg_size_px / 2); 340 | item_el.appendChild(rect_el); 341 | item_el.appendChild(text_el); 342 | item_el.appendChild(peg_el); 343 | mega_spinner_items.appendChild(item_el); 344 | new_item_els_list.push(item_el); 345 | } 346 | 347 | item_el.style.transform = `translateY(${(y - 1 / 2).toFixed(5) * item_height}px)`; 348 | 349 | item_el_index += 1; 350 | } 351 | to_remove_item_els = to_remove_item_els.concat(item_els.slice(item_el_index)); 352 | to_remove_item_els.forEach((el) => el.remove()); 353 | item_els = new_item_els_list; 354 | 355 | mega_spinner_ticker.style.transform = `translateY(-50%) rotate(${ticker_rotation_deg}deg) scaleY(0.5)`; 356 | 357 | }; 358 | 359 | const pass_peg_limit = 0.5; 360 | const peg_size = 0.1; 361 | const peg_size_px = 5; 362 | const peg_pushback = 1 / 2500000; 363 | const time_step = 1; // delta times are broken up into chunks this size or smaller 364 | const simulate_mega_spinner = (delta_time, audio_context_time) => { 365 | const prev_ticker_index_attachment = ticker_index_attachment; 366 | const prev_ticker_rotation_deg = ticker_rotation_deg; 367 | // I'm not totally sure this variable name makes sense: 368 | const ticker_index_occupancy = Math.round(spin_position + peg_size * Math.sign(spin_position - ticker_index_attachment)); 369 | if ( 370 | ticker_index_attachment !== ticker_index_occupancy && 371 | (mod(Math.abs(spin_position - ticker_index_attachment + 1 / 2 + peg_size * Math.sign(spin_position - ticker_index_attachment)), 1)) < pass_peg_limit 372 | ) { 373 | ticker_rotation_deg = (spin_position - ticker_index_attachment - (1 / 2 - peg_size) * Math.sign(spin_position - ticker_index_attachment)) * 38; 374 | // ticker_rotation_speed_deg_per_frame = spin_velocity * 50; 375 | spin_velocity -= ticker_rotation_deg * peg_pushback * delta_time; 376 | peg_hit_timer = 500; 377 | // Limit attachment to an adjacent item. 378 | // This fixes a case where you stop the spinner while it's moving fast and it's on a peg. 379 | // With the random limit below (not sure I'll keep that) it flipped out, but it could also just stay in one place but not in a physically plausible way 380 | // TODO: handle wrapping seamlessly? this is very much an edge case and you probably wouldn't notice 381 | // i.e. if one of these indices is 0 382 | if (ticker_index_attachment < ticker_index_occupancy) { 383 | ticker_index_attachment = ticker_index_occupancy - 1; 384 | } else { 385 | ticker_index_attachment = ticker_index_occupancy + 1; 386 | } 387 | } else { 388 | ticker_rotation_deg -= 0.03 * ticker_rotation_deg * delta_time; 389 | ticker_index_attachment = Math.round(spin_position); 390 | } 391 | 392 | if (prev_ticker_index_attachment !== ticker_index_attachment) { 393 | var tick_source = audio_context.createBufferSource(); 394 | tick_source.buffer = tick_sound; 395 | tick_source.start(audio_context_time); 396 | tick_source.playbackRate.setTargetAtTime( 397 | 1 + (Math.abs(prev_ticker_rotation_deg - ticker_rotation_deg) / 15 + Math.abs(spin_velocity)) / 15, 398 | audio_context_time + 0.02, 399 | 0.03 400 | ); 401 | tick_source.connect(audio_context.destination); 402 | } 403 | 404 | if (dragging) { 405 | spin_velocity = 0; 406 | } 407 | spin_position += spin_velocity * delta_time; 408 | spin_velocity -= 0.001 * spin_velocity * delta_time; 409 | 410 | // ticker_rotation_deg += ticker_rotation_speed_deg_per_frame; 411 | // ticker_rotation_speed_deg_per_frame *= 0.2; 412 | // ticker_rotation_speed_deg_per_frame -= ticker_rotation_deg / 50; 413 | const limit = 70 + Math.random() * 30; 414 | ticker_rotation_deg = Math.min(limit, Math.max(-limit, ticker_rotation_deg)); 415 | 416 | if (peg_hit_timer > 0) { 417 | peg_hit_timer -= delta_time; 418 | } 419 | }; 420 | 421 | let plinketto_animating = false; 422 | let plinketto_pegs = []; 423 | let plinketto_balls = []; // or pucks, but that looks too similar to "buckets" for visual scanning :) 424 | let plinketto_buckets = []; 425 | 426 | const render_plinketto = () => { 427 | for (const ball of plinketto_balls) { 428 | if (!ball.element) { 429 | ball.element = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 430 | ball.element.setAttribute("class", "plinketto-ball"); 431 | plinketto_svg.appendChild(ball.element); 432 | } 433 | ball.element.setAttribute("cx", ball.x); 434 | ball.element.setAttribute("cy", ball.y); 435 | ball.element.setAttribute("r", ball.radius); 436 | } 437 | for (const peg of plinketto_pegs) { 438 | if (!peg.element) { 439 | peg.element = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 440 | peg.element.setAttribute("class", "plinketto-peg"); 441 | plinketto_svg.appendChild(peg.element); 442 | } 443 | peg.element.setAttribute("cx", peg.x); 444 | peg.element.setAttribute("cy", peg.y); 445 | peg.element.setAttribute("r", peg.radius); 446 | } 447 | let i = 0; 448 | for (const bucket of plinketto_buckets) { 449 | i++; 450 | if (!bucket.element) { 451 | bucket.element = document.createElementNS("http://www.w3.org/2000/svg", "g"); 452 | bucket.element.setAttribute("class", "plinketto-bucket"); 453 | const rect_el = document.createElementNS("http://www.w3.org/2000/svg", "rect"); 454 | rect_el.setAttribute("fill", `hsl(${(i - 0.3) / Math.max(plinketto_buckets.length, 4)}turn, 80%, 50%)`); 455 | rect_el.setAttribute("x", bucket.x); 456 | rect_el.setAttribute("y", bucket.y); 457 | rect_el.setAttribute("width", bucket.width); 458 | rect_el.setAttribute("height", bucket.height); 459 | const text_el = document.createElementNS("http://www.w3.org/2000/svg", "text"); 460 | text_el.setAttribute("dominant-baseline", "middle"); 461 | text_el.setAttribute("text-anchor", "middle"); 462 | text_el.setAttribute("x", bucket.x + bucket.width / 2); 463 | text_el.setAttribute("y", bucket.y + bucket.height / 2); 464 | text_el.textContent = bucket.id; 465 | bucket.element.appendChild(rect_el); 466 | bucket.element.appendChild(text_el); 467 | plinketto_svg.appendChild(bucket.element); 468 | text_el.setAttribute("font-size", 1); 469 | const bbox = text_el.getBBox(); 470 | text_el.setAttribute("font-size", Math.min(7, (bucket.width - 2) / bbox.width)); 471 | } 472 | } 473 | }; 474 | 475 | const gravity = { x: 0, y: 0.0005 }; 476 | const air_friction_x = 0.001; 477 | const air_friction_y = 0.001; 478 | const collision_friction_x = 0.04; 479 | const collision_friction_y = 0.04; 480 | const wall_radius = 0.5; 481 | const peg_x_spacing = 5; 482 | const peg_y_spacing = 5; 483 | const outer_wall_x = (right, y) => right * 100 + (right ? -1 : 1) * Math.max( 484 | Math.cos(y / peg_y_spacing * Math.PI + Math.PI) - 5, 485 | (y - 91) / 4, // taper in near bottom 486 | ); 487 | const simulate_plinketto = (delta_time, audio_context_time) => { 488 | for (const ball of plinketto_balls) { 489 | ball.velocity_x += gravity.x * delta_time; 490 | ball.velocity_y += gravity.y * delta_time; 491 | ball.velocity_x -= ball.velocity_x * air_friction_x * delta_time; 492 | ball.velocity_y -= ball.velocity_y * air_friction_y * delta_time; 493 | ball.x += ball.velocity_x * delta_time; 494 | ball.y += ball.velocity_y * delta_time; 495 | let collision = false; 496 | for (const peg of plinketto_pegs) { 497 | const distance_of_centers = Math.hypot(ball.x - peg.x, ball.y - peg.y); 498 | const distance_of_edges = distance_of_centers - peg.radius - ball.radius; 499 | if (distance_of_edges < 0) { 500 | ball.velocity_x += (ball.x - peg.x) / distance_of_centers * 0.005 * delta_time; 501 | ball.velocity_y += (ball.y - peg.y) / distance_of_centers * 0.005 * delta_time; 502 | ball.velocity_x -= ball.velocity_x * collision_friction_x * delta_time; 503 | ball.velocity_y -= ball.velocity_y * collision_friction_y * delta_time; 504 | collision = true; 505 | } 506 | } 507 | 508 | const left_wall_x = outer_wall_x(false, ball.y); 509 | const right_wall_x = outer_wall_x(true, ball.y); 510 | // ignoring wall_radius for these two conditions so they only apply when the ball is moving fast, 511 | // and otherwise the pegs of the wall come into play 512 | if (ball.x + ball.radius > right_wall_x) { 513 | ball.velocity_x = -Math.abs(ball.velocity_x) * 0.9; 514 | ball.x = Math.min(ball.x, right_wall_x - wall_radius); 515 | collision = true; 516 | } 517 | if (ball.x - ball.radius < left_wall_x) { 518 | ball.velocity_x = Math.abs(ball.velocity_x) * 0.9; 519 | ball.x = Math.max(ball.x, left_wall_x + wall_radius); 520 | collision = true; 521 | } 522 | 523 | if (ball.y + ball.radius > 90) { 524 | ball.velocity_y = -Math.abs(ball.velocity_y) * 0.9; 525 | ball.y = Math.min(ball.y, 90 - ball.radius); 526 | collision = 2; 527 | } 528 | 529 | if (collision) { 530 | const panner = audio_context.createPanner(); 531 | panner.coneOuterGain = 1; // don't actually care about a cone, just want position 532 | panner.coneOuterAngle = 180; 533 | panner.coneInnerAngle = 0; 534 | panner.connect(audio_context.destination); 535 | if (panner.positionX) { 536 | panner.positionX.setValueAtTime((ball.x - 50) / 50, audio_context_time); 537 | panner.positionY.setValueAtTime((ball.y - 50) / 50, audio_context_time); 538 | panner.positionZ.setValueAtTime(-0.5, audio_context_time); 539 | } else { 540 | panner.setPosition((ball.x - 50) / 50, (ball.y - 50) / 50, -0.5); 541 | } 542 | 543 | const plink_gain = audio_context.createGain(); 544 | plink_gain.gain.setValueAtTime(0, 0); 545 | plink_gain.connect(panner); 546 | 547 | const plink_source = audio_context.createBufferSource(); 548 | plink_source.buffer = plink_sound; 549 | plink_source.start(audio_context_time); 550 | // plink_source.playbackRate.setTargetAtTime( 551 | // 1 + (Math.abs(prev_ticker_rotation_deg - ticker_rotation_deg) / 15 + Math.abs(spin_velocity)) / 15, 552 | // audio_context_time + 0.02, 553 | // 0.03 554 | // ); 555 | plink_source.connect(plink_gain); 556 | plink_source.playbackRate.setValueAtTime(Math.min(Math.hypot(ball.velocity_x, ball.velocity_y), 3) * 0.4 + (collision === 2 ? 1 : 1.9), audio_context_time); 557 | plink_gain.gain.setValueAtTime(Math.min(Math.hypot(ball.velocity_x, ball.velocity_y), 3), audio_context_time); 558 | plink_gain.gain.setTargetAtTime(0, audio_context_time + 0.3, 0.01); 559 | } 560 | } 561 | }; 562 | 563 | const handle_device_orientation = (event) => { 564 | if (typeof event.beta != "number" || typeof event.gamma != "number") { 565 | // Chrome seems to fire one bogus deviceorientation event 566 | // with null properties, on desktop. 567 | return; 568 | } 569 | // gamma is the left-to-right tilt in degrees, where right is positive 570 | // beta is the front-to-back tilt in degrees, where front is positive 571 | // alpha is the compass direction the device is facing in degrees 572 | gravity.y = Math.sin(event.beta * Math.PI / 180) * Math.cos(event.gamma * Math.PI / 180) * 0.001; 573 | gravity.x = Math.sin(event.gamma * Math.PI / 180) * 0.001; 574 | //ang = -Math.atan(x / y) + (y < 0 ? Math.PI : 0) + Math.PI / 2 // from x axis clockwise 575 | //r = Math.sqrt(x * x + y * y) 576 | }; 577 | 578 | const cleanup_plinketto = () => { 579 | window.removeEventListener("deviceorientation", handle_device_orientation); 580 | 581 | plinketto_container.hidden = true; 582 | plinketto_animating = false; 583 | 584 | // Array.from is necessary because childNodes is a live NodeList, updated as things are removed 585 | for (const child of Array.from(plinketto_svg.childNodes)) { 586 | child.remove(); 587 | } 588 | plinketto_buckets.length = 0; 589 | plinketto_balls.length = 0; 590 | plinketto_pegs.length = 0; 591 | }; 592 | 593 | const setup_plinketto = (options) => { 594 | cleanup_plinketto(); 595 | window.addEventListener("deviceorientation", handle_device_orientation, false); 596 | 597 | for (let i = 0; i < options.length; i += 1) { 598 | const x = i * 100 / options.length; 599 | plinketto_buckets.push({ 600 | id: options[i], 601 | x: x, 602 | y: 90, 603 | width: 100 / options.length, 604 | height: 10, 605 | }); 606 | if (i > 0) { 607 | for (let y = 80; y < 90; y += 1) { 608 | plinketto_pegs.push({ 609 | x, y, 610 | radius: 0.5, 611 | }); 612 | } 613 | } 614 | } 615 | for (let y = peg_y_spacing * 3; y < 80; y += peg_y_spacing) { 616 | for (let x = (y % (peg_y_spacing * 2)) ? peg_x_spacing / 2 : 0; x <= 100; x += peg_x_spacing) { 617 | plinketto_pegs.push({ 618 | x, y, 619 | radius: 0.8, 620 | }); 621 | } 622 | } 623 | for (let y = 0; y < 90; y += 1) { 624 | plinketto_pegs.push({ 625 | x: outer_wall_x(false, y), y, 626 | radius: 0.5, 627 | }); 628 | plinketto_pegs.push({ 629 | x: outer_wall_x(true, y), y, 630 | radius: 0.5, 631 | }); 632 | } 633 | // for (let i = 0; i < 50; i++) { 634 | // plinketto_balls.push({ 635 | // x: 50, 636 | // y: 1, 637 | // velocity_x: (Math.random() - 0.5) * 5, 638 | // velocity_y: 0, 639 | // radius: 1.2, 640 | // }); 641 | // } 642 | 643 | plinketto_balls.push({ 644 | x: 50, 645 | y: 1, 646 | velocity_x: (Math.random() - 0.5) * 5, 647 | velocity_y: 0, 648 | radius: 1.2, 649 | }); 650 | }; 651 | 652 | let rafid; 653 | let last_time = -1; 654 | const animate = (start_animating_what) => { 655 | if (start_animating_what === "mega_spinner") { 656 | if (mega_spinner_animating) { 657 | return; 658 | } 659 | mega_spinner_animating = true; 660 | } 661 | if (start_animating_what === "plinketto") { 662 | if (plinketto_animating) { 663 | return; 664 | } 665 | plinketto_animating = true; 666 | } 667 | rafid = requestAnimationFrame(animate); 668 | const now = performance.now(); 669 | if (last_time === -1) { 670 | last_time = now; 671 | } 672 | const delta_time = Math.min(now - last_time, 30); // limit needed to handle if the page isn't visible for a while; scalar can be refactored out 673 | 674 | let remaining_delta_time = delta_time; 675 | let audio_context_time = audio_context.currentTime; 676 | while (remaining_delta_time > 0) { 677 | const sub_time_step = Math.min(time_step, remaining_delta_time); 678 | simulate_mega_spinner(sub_time_step, audio_context_time); 679 | simulate_plinketto(sub_time_step, audio_context_time); 680 | remaining_delta_time -= sub_time_step; 681 | audio_context_time += sub_time_step / 1000; 682 | } 683 | 684 | const title_line = unfiltered_title_lines[title_line_indexes[mod(ticker_index_attachment, title_line_indexes.length)]]; 685 | const { title } = parse_title_line(title_line); 686 | const moved_away_from_displayed_title = title !== displayed_title; 687 | if (mega_spinner_animating) { 688 | if (Math.abs(spin_velocity) < 0.001 && !dragging && peg_hit_timer <= 0 && !plinketto_animating) { 689 | if (moved_away_from_displayed_title) { 690 | picked_title_line(title_line); 691 | if (Math.abs(spin_velocity) < 0.0001 && Math.abs(ticker_rotation_deg) < 0.01) { 692 | spin_velocity = 0; 693 | ticker_rotation_deg = 0; 694 | mega_spinner_animating = false; 695 | } 696 | } 697 | document.body.classList.remove("spinner-active"); 698 | } else if (moved_away_from_displayed_title) { 699 | document.body.classList.add("spinner-active"); 700 | result_container.style.opacity = 0; 701 | displayed_title = null; 702 | } 703 | } 704 | if (plinketto_animating) { 705 | if (plinketto_balls.every((ball) => Math.abs(ball.velocity_y) < 0.01 && ball.y + ball.radius >= 90 - 0.1)) { 706 | plinketto_animating = false; 707 | const ball = plinketto_balls[0]; 708 | const instance_text = plinketto_buckets.find((bucket) => bucket.x < ball.x && bucket.x + bucket.width > ball.x).id; 709 | display_result(title, instance_text); 710 | location.hash = `${title} (${instance_text})`; 711 | cleanup_plinketto(); 712 | } 713 | } 714 | 715 | render_mega_spinner(); 716 | render_plinketto(); 717 | 718 | if (!mega_spinner_animating && !plinketto_animating) { 719 | cancelAnimationFrame(rafid); 720 | } 721 | 722 | last_time = now; 723 | }; 724 | 725 | const normalizations = [ 726 | "ONE", 727 | "TWO", 728 | "THREE", 729 | "FOUR", 730 | "FIVE", 731 | "SIX", 732 | "SEVEN", 733 | "EIGHT", 734 | "NINE", 735 | "TEN", 736 | "ELEVEN", 737 | "TWELVE", 738 | "VERSUS", 739 | // should this include roman numerals? 740 | // ensure a substring of a matching search will ALWAYS match, or would it be weird? 741 | ]; 742 | const normalize_title = (title) => 743 | // Note: I is a common word; might want to not normalize it in some cases 744 | // Note: some titles use numbers, especially 2, in different ways, like in place of "to" or "too" 745 | title.normalize("NFKD") 746 | .toLocaleUpperCase() 747 | .replace(/[\u0300-\u036f]/g, "") // probably not needed with "NFKD" 748 | .replace(/\s(VS?\.?|VERSUS)(\s|$)/g, " {VERSUS} ") 749 | .replace(/\b(1|I(?!,|\s)|ONE)\b/g, "{1}") 750 | .replace(/\b(2|II|TWO)\b/g, "{2}") 751 | .replace(/\b(3|III|THREE)\b/g, "{3}") 752 | .replace(/\b(4|IV|IIII|FOUR)\b/g, "{4}") 753 | .replace(/\b(5|V(?!\s)|IIIII|FIVE)\b/g, "{5}") 754 | .replace(/\b(6|VI|IIIIII|IIIIX|SIX)\b/g, "{6}") 755 | .replace(/\b(7|VII|IIIX|SEVEN)\b/g, "{7}") 756 | .replace(/\b(8|VIII|IIX|EIGHT)\b/g, "{8}") 757 | .replace(/\b(9|IX|VIIII|NINE)\b/g, "{9}") 758 | .replace(/\b(10|X|TEN)\b/g, "{10}") 759 | .replace(/\b(11|XI|ELEVEN)\b/g, "{11}") 760 | .replace(/\b(12|XII|TWELVE)\b/g, "{12}"); 761 | 762 | const search_matches_normalized_title = (search, normalized_title) => { 763 | const simply_normalized_search = normalize_title(search); 764 | // if (normalized_title.slice(0, simply_normalized_search.length) === simply_normalized_search) { 765 | if (normalized_title.indexOf(simply_normalized_search) > -1) { 766 | return true; 767 | } 768 | const search_upper = search.toLocaleUpperCase(); 769 | for (const normalization of normalizations) { 770 | for (let i = 1; i < normalization.length; i++) { 771 | if (search_upper.slice(search_upper.length - i) === normalization.slice(0, i)) { 772 | const autocompleted = `${search_upper}${normalization.slice(i)}`; 773 | const autocomplete_normalized = normalize_title(autocompleted); 774 | // if (normalized_title.slice(0, autocomplete_normalized.length) === autocomplete_normalized) { 775 | if (normalized_title.indexOf(autocomplete_normalized) > -1) { 776 | return true; 777 | } 778 | } 779 | } 780 | } 781 | return false; 782 | }; 783 | 784 | const search_matches_title = (search, title) => search_matches_normalized_title(search, normalize_title(title)); 785 | 786 | const show_normalization = (a, b) => { 787 | const indent = " "; 788 | return ` 789 | Original titles: 790 | ${indent}"${a}" 791 | ${indent}"${b}" 792 | 793 | Normalized: 794 | ${indent}"${normalize_title(a)}" 795 | ${indent}"${normalize_title(b)}" 796 | `; 797 | }; 798 | for (const [a, b] of [ 799 | ["Psycho IV: The Beginning (1990)", "Psycho 4: The Beginning (1990)"], 800 | ["1: The Movie", "One: The Movie"], 801 | ["The Human Condition I: No Greater Love (1959)", "The Human Condition One: No Greater Love (1959)"], 802 | ["The Uchōten Hotel", "The Uchoten Hotel"], 803 | ["Omen III", "Omen Three"], 804 | ["Meatballs II", "Meatballs 2"], 805 | ["Star Wars: Episode VI: Return of the Jedi (1983)", "Star Wars: Episode Six: Return of the Jedi (1983)"], 806 | ["Star Wars: Episode VI: Return of the Jedi (1983)", "Star Wars: Episode Ⅵ: Return of the Jedi (1983)"], 807 | ["2 Fast 2 Furious (2003)", "Two Fast II Furious (2003)"], 808 | // ["2 Fast 2 Furious (2003)", "Too Fast Too Furious (2003)"], 809 | ["Roe v. Wade", "Roe vs. Wade"], 810 | ]) { 811 | if (console && console.assert) { 812 | console.assert(normalize_title(a) === normalize_title(b), `Normalized titles should be equal. 813 | ${show_normalization(a, b)} 814 | `); 815 | } 816 | } 817 | for (const [a, b] of [ 818 | ["Furious (200three)", "Furious (2003)"], 819 | ["Furious (II003)", "Furious (2003)"], 820 | ["One, t", "I, T"], // I, The Jury 821 | ["I as in Icarus (1979)", "One as in Icarus (1979)"], 822 | // ["Hallelujah, I'm a Bum (1933)", "Hallelujah, ONE'm a Bum (1933)"], // TODO 823 | ]) { 824 | if (console && console.assert) { 825 | console.assert(normalize_title(a) !== normalize_title(b), `Normalized titles SHOULD NOT BE EQUAL but are. 826 | ${show_normalization(a, b)} 827 | `); 828 | } 829 | } 830 | for (const [a, b] of [ 831 | [":", "ACAB: All Cops Are Bastards (2012)"], 832 | ["The Smurfs Tw", "The Smurfs 2"], 833 | ["Tw", "American Kickboxer 2"], 834 | ["I, t", "I, The Jury"], 835 | ["One, t", "One, two, three"], 836 | ["One, two, th", "One, two, three"], 837 | ["Batman vs", "Batman v Superman"], 838 | ["Batman vers", "Batman v Superman"], 839 | ["Batman vers", "Batman vs. Two-Face"], 840 | // ["Hallelujah, I'm a Bum (1933)", ", I"], // TODO 841 | // ["Hallelujah, I'm a Bum (1933)", "(1"], // TODO 842 | ]) { 843 | if (console && console.assert) { 844 | console.assert(search_matches_title(a, b), `Search should match. 845 | ${show_normalization(a, b)} 846 | (Note: search may do dynamic normalization) 847 | `); 848 | } 849 | } 850 | const parse_from_location_hash = () => { 851 | const hash = decodeURIComponent(location.hash.replace(/^#/, "")); 852 | if (!hash) { 853 | clear_result(); 854 | return; 855 | } 856 | const parsed = parse_title_line(hash) || { title: hash, parenthetical: "", instances: [] }; 857 | const normalized_title = normalize_title(parsed.title); 858 | const normalized_parenthetical = normalize_title(parsed.parenthetical); 859 | for (let item_index = 0; item_index < title_line_indexes.length; item_index++) { 860 | const title_line_index = title_line_indexes[item_index]; 861 | const title_line = unfiltered_title_lines[title_line_index]; 862 | const normalized_title_line = normalized_unfiltered_title_lines[title_line_index]; 863 | if (normalized_title_line.slice(0, normalized_title.length) === normalized_title) { // optimization 864 | const movie = parse_title_line(title_line); 865 | if (!movie) { 866 | console.warn("movie title line didn't parse:", title_line); 867 | } 868 | if ( 869 | normalize_title(movie.title) === normalized_title && 870 | normalize_title(movie.parenthetical).indexOf(normalized_parenthetical) > -1 871 | ) { 872 | if (!displayed_title || normalize_title(displayed_title) !== normalized_title) { 873 | spin_position = item_index; 874 | spin_velocity = 0; 875 | ticker_index_attachment = item_index; 876 | ticker_rotation_deg = 0; 877 | mega_spinner_animating = false; 878 | // ticker_rotation_speed_deg_per_frame = 0; 879 | const instance_index = movie.instances.map(normalize_title).indexOf(normalized_parenthetical); 880 | if (instance_index !== -1) { 881 | const instance_text = movie.instances[instance_index]; 882 | display_result(movie.title, instance_text); 883 | } else { 884 | picked_title_line(title_line); 885 | } 886 | 887 | render_mega_spinner(); 888 | } 889 | } 890 | } 891 | } 892 | }; 893 | 894 | const apply_filters = () => { 895 | const invalidate = () => { 896 | for (const item_el of item_els) { 897 | item_el.remove(); 898 | } 899 | item_els.length = 0; 900 | // displayed_title = null; 901 | clear_result(); 902 | render_mega_spinner(); 903 | parse_from_location_hash(); 904 | // spin_position = mod(spin_position, title_line_indexes.length); 905 | // ticker_index_attachment = mod(ticker_index_attachment, title_line_indexes.length); 906 | // if (!isFinite(spin_position)) { 907 | // spin_position = 0; 908 | // } 909 | // if (!isFinite(ticker_index_attachment)) { 910 | // ticker_index_attachment = 0; 911 | // } 912 | }; 913 | title_line_indexes = [...shuffled_unfiltered_title_line_indexes]; 914 | if (title_filter.value === "") { 915 | invalidate(); 916 | return; 917 | } 918 | const search = title_filter.value; 919 | // const normalized_search = normalize_title(search); // might want something like this as an optimization 920 | title_line_indexes = title_line_indexes.filter((title_line_index) => { 921 | const normalized_title_line = normalized_unfiltered_title_lines[title_line_index]; 922 | return search_matches_normalized_title(search, normalized_title_line); 923 | }); 924 | if (title_line_indexes.length === 0) { 925 | title_line_indexes = [...shuffled_unfiltered_title_line_indexes]; 926 | } 927 | invalidate(); 928 | }; 929 | 930 | const main = async () => { 931 | const response = await fetch("movies.txt"); 932 | // const response = await fetch("test-subtitles.txt"); 933 | const text = await response.text(); 934 | 935 | unfiltered_title_lines = text.trim().split(/\r?\n/g); 936 | normalized_unfiltered_title_lines = unfiltered_title_lines.map(normalize_title); 937 | 938 | title_line_indexes = new Int32Array(unfiltered_title_lines.length); 939 | for (let i = 0; i < title_line_indexes.length; i++) { 940 | title_line_indexes[i] = i; 941 | } 942 | const prng = sfc32(1, 2, 3, 4); 943 | shuffle(title_line_indexes, prng); 944 | 945 | shuffled_unfiltered_title_line_indexes = new Int32Array(title_line_indexes); 946 | 947 | spin_position = Math.random() * title_line_indexes.length; 948 | ticker_index_attachment = spin_position; 949 | 950 | parse_from_location_hash(); 951 | 952 | window.addEventListener("hashchange", parse_from_location_hash); 953 | 954 | render_mega_spinner(); 955 | 956 | window.addEventListener("resize", render_mega_spinner); 957 | 958 | mega_spinner_svg.style.touchAction = "none"; 959 | mega_spinner_svg.style.userSelect = "none"; 960 | mega_spinner_items.style.cursor = "grab"; 961 | mega_spinner_items.addEventListener("selectstart", (event) => { 962 | event.preventDefault(); 963 | }); 964 | mega_spinner_items.addEventListener("pointerdown", (event) => { 965 | dragging = true; 966 | spin_velocity = 0; 967 | mega_spinner_items.style.cursor = "grabbing"; 968 | const item_height = parseFloat(getComputedStyle(mega_spinner_items).getPropertyValue("--item-height")); 969 | const start_y = event.clientY; 970 | const start_spin_position = spin_position; 971 | let y_velocity_energy = 0; 972 | let last_event_time = performance.now(); 973 | let last_event_y = start_y; 974 | const on_pointer_move = (event) => { 975 | const new_y = event.clientY; 976 | const new_time = performance.now(); 977 | spin_position = start_spin_position + (new_y - start_y) / item_height; 978 | animate("mega_spinner"); 979 | y_velocity_energy += (new_y - last_event_y) * (new_time - last_event_time) / item_height; 980 | last_event_time = new_time; 981 | last_event_y = new_y; 982 | }; 983 | let iid = setInterval(() => { 984 | y_velocity_energy *= 0.8; 985 | }); 986 | const on_pointer_up = (event) => { 987 | window.removeEventListener("pointermove", on_pointer_move); 988 | window.removeEventListener("pointerup", on_pointer_up); 989 | window.removeEventListener("pointercancel", on_pointer_up); 990 | clearInterval(iid); 991 | mega_spinner_items.style.cursor = "grab"; 992 | if (event.type !== "pointercancel") { 993 | spin_velocity = y_velocity_energy / 250; 994 | } 995 | dragging = false; 996 | animate("mega_spinner"); 997 | }; 998 | window.addEventListener("pointermove", on_pointer_move); 999 | window.addEventListener("pointerup", on_pointer_up); 1000 | window.addEventListener("pointercancel", on_pointer_up); 1001 | }); 1002 | 1003 | go_button.onclick = () => { 1004 | spin_velocity = 5 + Math.random() * 5; 1005 | animate("mega_spinner"); 1006 | }; 1007 | 1008 | window.addEventListener("keydown", (event) => { 1009 | if (event.ctrlKey || event.metaKey && !event.altKey && !event.shiftKey) { 1010 | if (event.key.toUpperCase() === "C") { 1011 | if ( 1012 | window.getSelection().isCollapsed && ( 1013 | !document.activeElement.matches("textarea, input") || 1014 | document.activeElement.selectionEnd === document.activeElement.selectionStart 1015 | ) 1016 | ) { 1017 | event.preventDefault(); 1018 | copy_to_clipboard_button.click(); 1019 | } 1020 | } else if (event.key.toUpperCase() === "F") { 1021 | event.preventDefault(); 1022 | filters.hidden = false; 1023 | title_filter.focus(); 1024 | title_filter.select(); 1025 | } 1026 | } else if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { 1027 | if (event.key === "Escape") { 1028 | if (!filters.hidden) { 1029 | filters.hidden = true; 1030 | } else if (!plinketto_container.hidden || plinketto_animating) { 1031 | cleanup_plinketto(); 1032 | const title_line = unfiltered_title_lines[title_line_indexes[mod(ticker_index_attachment, title_line_indexes.length)]]; 1033 | quick_pick(title_line); 1034 | } 1035 | } else if (event.key === "Enter" || event.key === "Return") { 1036 | if (event.target.closest("#filters")) { 1037 | apply_filters(); 1038 | } 1039 | } 1040 | } 1041 | }); 1042 | 1043 | close_filters_button.addEventListener("click", () => { 1044 | filters.hidden = true; 1045 | }); 1046 | 1047 | open_filters_button_link.addEventListener("click", (event) => { 1048 | event.preventDefault(); // It's actually a link ( element) 1049 | filters.hidden = false; 1050 | title_filter.focus(); 1051 | title_filter.select(); 1052 | }); 1053 | 1054 | title_filter.addEventListener("input", apply_filters); 1055 | 1056 | window.addEventListener("transitionstart", (event) => { 1057 | if (!event.target.matches("#info, #mega-spinner")) { 1058 | return; 1059 | } 1060 | const iid = setInterval(() => { 1061 | fitty.fitAll(); 1062 | }, 100); 1063 | const on_transition_end = () => { 1064 | fitty.fitAll(); 1065 | clearInterval(iid); 1066 | window.removeEventListener("transitionend", on_transition_end); 1067 | }; 1068 | window.addEventListener("transitionend", on_transition_end); 1069 | }); 1070 | 1071 | load_sound("tick.wav").then((sound) => { tick_sound = sound; }); 1072 | load_sound("clink.wav").then((sound) => { plink_sound = sound; }); 1073 | 1074 | // TODO: remove duplicate movie listings 1075 | // window.titles = new Map(); 1076 | // for (const title_line of unfiltered_title_lines) { 1077 | // const parsed = parse_title_line(title_line); 1078 | // const normalized_title = normalize_title(parsed.title); 1079 | // const existing = titles.get(normalized_title); 1080 | // if (existing) { 1081 | // if (parsed.title !== existing.title) { 1082 | // console.log("different titles!", parsed.title, "vs", existing.title); 1083 | // if (parsed.parenthetical !== existing.parenthetical) { 1084 | // console.log("also different parentheticals", existing.parenthetical, "vs", parsed.parenthetical); 1085 | // } 1086 | // } else if (parsed.parenthetical !== existing.parenthetical) { 1087 | // console.log(`different parentheticals for "${parsed.title}"`, existing.parenthetical, "vs", parsed.parenthetical); 1088 | // } 1089 | // } 1090 | // titles.set(normalized_title, parsed); 1091 | // } 1092 | 1093 | // fine: 1094 | 1095 | // different titles! Ten Years vs 10 Years 1096 | // also different parentheticals 2011 vs 2015 1097 | 1098 | // to investigate: 1099 | 1100 | // different titles! Twelve vs 12 1101 | // also different parentheticals 2003 & 2007 vs 2010 1102 | 1103 | // different titles! Three Godfathers vs 3 Godfathers 1104 | // also different parentheticals 1948 vs 1936 1105 | 1106 | // different titles! Five Fingers vs 5 Fingers 1107 | // also different parentheticals 1952 vs 2006 1108 | 1109 | // different titles! Nine vs 9 1110 | // also different parentheticals 2005 short & 2009 vs 2009 1111 | 1112 | // different titles! 5 Fingers vs Five Fingers 1113 | // also different parentheticals 2006 vs 1952 1114 | 1115 | // different titles! 9 vs Nine 1116 | // also different parentheticals 2009 vs 2005 short & 2009 1117 | 1118 | // different titles! 12 vs Twelve 1119 | // also different parentheticals 2010 vs 2007 1120 | 1121 | // different titles! Angel vs Ángel 1122 | // also different parentheticals 2007 vs 1937, 1966 short, 1982 Greek, 1982 Irish, 1984, 1987, 2007, 2009 & 2011 1123 | 1124 | // different titles! Boogeyman II vs Boogeyman 2 1125 | // also different parentheticals 2008 vs 1983 1126 | 1127 | // different titles! Doppelganger vs Doppelgänger 1128 | // also different parentheticals 1969 vs 1993 1129 | 1130 | // different titles! Kid vs KID 1131 | // also different parentheticals 2015 vs 1990, 2012 & 2015 1132 | 1133 | // different titles! Napoléon vs Napoleon 1134 | // also different parentheticals 1951, 1994, 1995 & 2007 vs 1927 & 1955 1135 | 1136 | // different titles! Naughty but Nice vs Naughty But Nice 1137 | // also different parentheticals 1927 vs 1939 1138 | 1139 | // different titles! Nine vs 9 1140 | // also different parentheticals 2005 short & 2009 vs 2009 1141 | 1142 | // different titles! Noëlle vs Noelle 1143 | // also different parentheticals 2019 vs 2007 1144 | 1145 | // different titles! Regeneration vs ReGeneration 1146 | // also different parentheticals 2010 vs 1915 & 1997 1147 | 1148 | // different titles! ReGeneration vs Regeneration 1149 | // also different parentheticals 1915 & 1997 vs 2010 1150 | 1151 | // different titles! Rocketman vs RocketMan 1152 | // also different parentheticals 1997 vs 2019 1153 | 1154 | // different titles! Roe vs. Wade vs Roe v. Wade 1155 | // also different parentheticals 2019 vs 1989 1156 | 1157 | // different titles! SubUrbia vs Suburbia 1158 | // also different parentheticals 1984 vs 1996 1159 | 1160 | // different titles! Three Women vs 3 Women 1161 | // also different parentheticals 1977 vs 1924, 1949 & 1952 1162 | 1163 | // different titles! Twelve vs 12 1164 | // also different parentheticals 2007 vs 2010 1165 | 1166 | // different titles! X vs 10 1167 | // also different parentheticals 1979 vs 1986 1168 | 1169 | // different titles! Zatōichi vs Zatoichi 1170 | // also different parentheticals 1989 vs 2003 1171 | 1172 | 1173 | // different parentheticals for "2 Fast 2 Furious" 2003 vs 2004 1174 | // different parentheticals for "The Three Musketeers" 1921, 1933, 1948, 1973, 1992, 1993 & 2011 vs 1921, 1933, 1973, 1992 & 1993 1175 | // different parentheticals for "Three Sisters" 1966, 1970, 1970 Olivier & 1994 vs 1970 Olivier & 1994 1176 | // different parentheticals for "Four Feathers" 1939, 1978 & 2002 vs 1939 & 2002 1177 | // different parentheticals for "Ten Little Indians" 1965 & 1989 vs 1965 1178 | // different parentheticals for "Ten Little Indians" 1965 vs 1989 1179 | // different parentheticals for "12 Angry Men" 1957 & 1997 vs 1957 1180 | // different parentheticals for "16 Blocks" 2005 vs 2006 1181 | // different parentheticals for "The 39 Steps" 1935, 1959, 1978 & 2008 TV vs 1935, 1959 & 1978 1182 | // different parentheticals for "300: Rise of an Empire" 2014 vs 2013 1183 | // different parentheticals for "1984" 1956 vs 1984 1184 | // different parentheticals for "Aladdin" 1992 Golden Films, 1992 Disney vs 1992 & 2019 1185 | // different parentheticals for "Alice" 1982, 1988, 1990 & 2005 vs 2012 1186 | // different parentheticals for "The Apartment" 1996 vs 1960 1187 | // different parentheticals for "Arsenal" 1929 vs 2017 1188 | // different parentheticals for "Avatar" 1916 & 2004 vs 2009 1189 | // different parentheticals for "Beyond" 2003 vs 1921, 2010, 2012 & 2014 1190 | // different parentheticals for "The Bourne Identity" 1988 TV vs 2002 1191 | // different parentheticals for "Calendar Girls" 2003 vs 2015 1192 | // different parentheticals for "Center Stage" 2000 vs 1991 1193 | // different parentheticals for "Chang Chen Ghost Stories" 2015 vs 2016 1194 | // different parentheticals for "Children of the Corn" 1984 vs 2009 1195 | // different parentheticals for "Control" 2005 vs 2007 1196 | // different parentheticals for "Cypher" 2002 vs 2019 1197 | // different parentheticals for "The Eye" 2002 vs 2008 1198 | // different parentheticals for "Fanaa" 2006 vs 2010 1199 | // different parentheticals for "The Fast and the Furious" 1955 vs 2001 1200 | // different parentheticals for "2 Fast 2 Furious" 2004 vs 2003 1201 | // different parentheticals for "Four Horsemen of the Apocalypse" 1962 vs 1921 & 1962 1202 | // different parentheticals for "The Four Seasons" 1981 vs 1979 & 1981 1203 | // different parentheticals for "The Fourth Man" 1983 vs 1983 & 2007 1204 | // different parentheticals for "House" 1977 & 2008 vs 1986 1205 | // different parentheticals for "How to Swim" 1942 vs 2018 1206 | // different parentheticals for "Hush" 1998 & 2004 TV vs 2016 1207 | // different parentheticals for "An Inspector Calls" 2015 vs 1954 1208 | // different parentheticals for "Casino Royale" 1967 & 2006 vs 2006 1209 | // different parentheticals for "Jason X" 2001 vs 2002 1210 | // different parentheticals for "Joy Ride" 1935 & 2000 vs 2001 1211 | // different parentheticals for "The Lover" 1986 vs 1992 1212 | // different parentheticals for "Million Dollar Baby" 2004 vs 1941 & 2004 1213 | // different parentheticals for "Millions" 2004 vs 1937 & 2004 1214 | // different parentheticals for "Les Misérables" 1909, 1917, 1925, 1934, 1935, 1948, 1952, 1958, 1967, 1978, 1982, 1995, 1998 & 2012 vs 1909, 1917, 1925, 1934, 1935, 1948, 1952, 1958, 1978, 1982, 1995, 1998 & 2012 1215 | // different parentheticals for "Montana" 1950, 1998 & 2014 vs 2017 1216 | // different parentheticals for "My Best Friend's Wedding" 1997 vs 2016 1217 | // different parentheticals for "My Cousin Rachel" 1952 vs 2017 1218 | // different parentheticals for "Mystery" 2012 vs 2014 1219 | // different parentheticals for "The Night Flier" 1997 vs 1998 1220 | // different parentheticals for "A Nightmare on Elm Street" 1984 & 2010 vs 1984 1221 | // different parentheticals for "A Nightmare on Elm Street" 1984 vs 2010 1222 | // different parentheticals for "The One" 2001 vs 2001 & 2003 1223 | // different parentheticals for "One More Time" 1970 vs 1931, 1970 & 2015 1224 | // different parentheticals for "Open Season" 1974 vs 2006 1225 | // different parentheticals for "Police Story" 1987 vs 1996 1226 | // different parentheticals for "Police Story 2" 1988 vs 2007 1227 | // different parentheticals for "Pride and Prejudice" 1955, 1998, 2004, 2007 & 2014 vs 1940, 2003 & 2005 1228 | // different parentheticals for "Race" 2007, 2011, 2013 & 2016 vs 2008 1229 | // different parentheticals for "Saw" 2003 vs 2004 1230 | // different parentheticals for "Sayonara" 1957 vs 2015 1231 | // different parentheticals for "The Scorpion King" 1992 vs 2002 1232 | // different parentheticals for "Metallica: Some Kind of Monster" 2003 vs 2004 1233 | // different parentheticals for "Stage Fright" 1923, 1940, 1950, 1987, 1989, 1997 & 2014 vs 1987 & 2013 1234 | // different parentheticals for "Superman" 1941 vs 1978 1235 | // different parentheticals for "Taxi" 2004 vs 1998 1236 | // different parentheticals for "The Ten" 2007 vs 2008 1237 | // different parentheticals for "The Ten Commandments" 1923, 1956, 2007 vs 1923 & 1956 1238 | // different parentheticals for "Ten Little Indians" 1989 vs 1965 1239 | // different parentheticals for "Thirteen Ghosts" 2001 vs 1960 & 2001 1240 | // different parentheticals for "The Three Musketeers" 1921, 1933, 1973, 1992 & 1993 vs 1921, 1933 serial, 1973, 1992 & 1993 1241 | // different parentheticals for "Three Sisters" 1970 Olivier & 1994 vs 1966, 1970, 1970 Olivier & 1994 1242 | // different parentheticals for "The Three Stooges" 2000 vs 2012 1243 | // different parentheticals for "Three Young Texans" 1954 vs 1953 1244 | // different parentheticals for "Turbulence" 2000 & 2011 vs 1997 1245 | // different parentheticals for "The Twelve Chairs" 1962, 1970, 1971 & 1976 vs 1970, 1971 & 1976 1246 | // different parentheticals for "Twilight" 1998 vs 2008 1247 | // different parentheticals for "The Two Jakes" 1990 vs 1974 1248 | // different parentheticals for "Two Women" 1960 vs 1960, 1947 & 1999 1249 | // different parentheticals for "Underworld" 1927, 1937, 1985, 1996 & 2004 vs 2003 1250 | // different parentheticals for "Universal Soldier" 1971 vs 1992 1251 | // different parentheticals for "V.I.P." 2017 vs 1989 & 1997 1252 | // different parentheticals for "Vampires" 1986 vs 1998 1253 | // different parentheticals for "Venom" 1981 & 2005 vs 2018 1254 | // different parentheticals for "Voices" 1973 & 1979 vs 1973, 1979 & 2007 1255 | // different parentheticals for "Witchcraft" 1916 & 1964 vs 1988 1256 | // different parentheticals for "XXX" 2016 vs 2002 1257 | // different parentheticals for "Zero Tolerance" 1995 vs 1995, 1999 & 2015 1258 | 1259 | }; 1260 | 1261 | main(); 1262 | -------------------------------------------------------------------------------- /clink.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1j01/true-random-movie/1cde02e53084a0c7c70562a710625e6f1277a874/clink.wav -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 41 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 70 | 73 | 80 | 86 | 87 | 94 | 100 | 101 | 108 | 114 | 115 | 122 | 128 | 129 | 136 | 142 | 143 | 154 | 160 | 161 | 162 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | True Random Movie 8 | 9 | 10 | 11 | 277 | 278 | 279 | 280 |
281 |

True Random Movie

282 |
Source Code on GitHub | Donate | 🔎Filter 285 |
286 |
287 |
288 | 289 | 290 |
291 | 297 |
298 |
299 |
300 | 301 | 302 | 303 |
304 | 307 | 314 | 315 | 316 | 317 | -------------------------------------------------------------------------------- /lib/fitty.module.js: -------------------------------------------------------------------------------- 1 | /* 2 | * fitty v2.3.2 - Snugly resizes text to fit its parent container 3 | * Copyright (c) 2020 Rik Schennink (https://pqina.nl/) 4 | */ 5 | 'use strict'; 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | export default (function (w) { 10 | 11 | // no window, early exit 12 | if (!w) return; 13 | 14 | // node list to array helper method 15 | var toArray = function toArray(nl) { 16 | return [].slice.call(nl); 17 | }; 18 | 19 | // states 20 | var DrawState = { 21 | IDLE: 0, 22 | DIRTY_CONTENT: 1, 23 | DIRTY_LAYOUT: 2, 24 | DIRTY: 3 25 | }; 26 | 27 | // all active fitty elements 28 | var fitties = []; 29 | 30 | // group all redraw calls till next frame, we cancel each frame request when a new one comes in. If no support for request animation frame, this is an empty function and supports for fitty stops. 31 | var redrawFrame = null; 32 | var redrawNow = function () { 33 | w.cancelAnimationFrame(redrawFrame); 34 | return redraw(fitties.filter(function (f) { 35 | return f.dirty && f.active; 36 | })); 37 | }; 38 | var requestRedraw = 'requestAnimationFrame' in w ? function () { 39 | w.cancelAnimationFrame(redrawFrame); 40 | redrawFrame = w.requestAnimationFrame(redrawNow); 41 | } : function () {}; 42 | 43 | // sets all fitties to dirty so they are redrawn on the next redraw loop, then calls redraw 44 | var redrawAll = function redrawAll(type) { 45 | return function () { 46 | fitties.forEach(function (f) { 47 | return f.dirty = type; 48 | }); 49 | requestRedraw(); 50 | }; 51 | }; 52 | 53 | // redraws fitties so they nicely fit their parent container 54 | var redraw = function redraw(fitties) { 55 | 56 | // getting info from the DOM at this point should not trigger a reflow, let's gather as much intel as possible before triggering a reflow 57 | 58 | // check if styles of all fitties have been computed 59 | fitties.filter(function (f) { 60 | return !f.styleComputed; 61 | }).forEach(function (f) { 62 | f.styleComputed = computeStyle(f); 63 | }); 64 | 65 | // restyle elements that require pre-styling, this triggers a reflow, please try to prevent by adding CSS rules (see docs) 66 | fitties.filter(shouldPreStyle).forEach(applyStyle); 67 | 68 | // we now determine which fitties should be redrawn 69 | var fittiesToRedraw = fitties.filter(shouldRedraw); 70 | 71 | // we calculate final styles for these fitties 72 | fittiesToRedraw.forEach(calculateStyles); 73 | 74 | // now we apply the calculated styles from our previous loop 75 | fittiesToRedraw.forEach(function (f) { 76 | applyStyle(f); 77 | markAsClean(f); 78 | }); 79 | 80 | // now we dispatch events for all restyled fitties 81 | fittiesToRedraw.forEach(dispatchFitEvent); 82 | }; 83 | 84 | var markAsClean = function markAsClean(f) { 85 | return f.dirty = DrawState.IDLE; 86 | }; 87 | 88 | var calculateStyles = function calculateStyles(f) { 89 | 90 | // get available width from parent node 91 | f.availableWidth = f.element.parentNode.clientWidth; 92 | 93 | // the space our target element uses 94 | f.currentWidth = f.element.scrollWidth; 95 | 96 | // remember current font size 97 | f.previousFontSize = f.currentFontSize; 98 | 99 | // let's calculate the new font size 100 | f.currentFontSize = Math.min(Math.max(f.minSize, f.availableWidth / f.currentWidth * f.previousFontSize), f.maxSize); 101 | 102 | // if allows wrapping, only wrap when at minimum font size (otherwise would break container) 103 | f.whiteSpace = f.multiLine && f.currentFontSize === f.minSize ? 'normal' : 'nowrap'; 104 | }; 105 | 106 | // should always redraw if is not dirty layout, if is dirty layout, only redraw if size has changed 107 | var shouldRedraw = function shouldRedraw(f) { 108 | return f.dirty !== DrawState.DIRTY_LAYOUT || f.dirty === DrawState.DIRTY_LAYOUT && f.element.parentNode.clientWidth !== f.availableWidth; 109 | }; 110 | 111 | // every fitty element is tested for invalid styles 112 | var computeStyle = function computeStyle(f) { 113 | 114 | // get style properties 115 | var style = w.getComputedStyle(f.element, null); 116 | 117 | // get current font size in pixels (if we already calculated it, use the calculated version) 118 | f.currentFontSize = parseInt(style.getPropertyValue('font-size'), 10); 119 | 120 | // get display type and wrap mode 121 | f.display = style.getPropertyValue('display'); 122 | f.whiteSpace = style.getPropertyValue('white-space'); 123 | }; 124 | 125 | // determines if this fitty requires initial styling, can be prevented by applying correct styles through CSS 126 | var shouldPreStyle = function shouldPreStyle(f) { 127 | 128 | var preStyle = false; 129 | 130 | // if we already tested for prestyling we don't have to do it again 131 | if (f.preStyleTestCompleted) return false; 132 | 133 | // should have an inline style, if not, apply 134 | if (!/inline-/.test(f.display)) { 135 | preStyle = true; 136 | f.display = 'inline-block'; 137 | } 138 | 139 | // to correctly calculate dimensions the element should have whiteSpace set to nowrap 140 | if (f.whiteSpace !== 'nowrap') { 141 | preStyle = true; 142 | f.whiteSpace = 'nowrap'; 143 | } 144 | 145 | // we don't have to do this twice 146 | f.preStyleTestCompleted = true; 147 | 148 | return preStyle; 149 | }; 150 | 151 | // apply styles to single fitty 152 | var applyStyle = function applyStyle(f) { 153 | f.element.style.whiteSpace = f.whiteSpace; 154 | f.element.style.display = f.display; 155 | f.element.style.fontSize = f.currentFontSize + 'px'; 156 | }; 157 | 158 | // dispatch a fit event on a fitty 159 | var dispatchFitEvent = function dispatchFitEvent(f) { 160 | f.element.dispatchEvent(new CustomEvent('fit', { 161 | detail: { 162 | oldValue: f.previousFontSize, 163 | newValue: f.currentFontSize, 164 | scaleFactor: f.currentFontSize / f.previousFontSize 165 | } 166 | })); 167 | }; 168 | 169 | // fit method, marks the fitty as dirty and requests a redraw (this will also redraw any other fitty marked as dirty) 170 | var fit = function fit(f, type) { 171 | return function () { 172 | f.dirty = type; 173 | if (!f.active) return; 174 | requestRedraw(); 175 | }; 176 | }; 177 | 178 | var init = function init(f) { 179 | 180 | // save some of the original CSS properties before we change them 181 | f.originalStyle = { 182 | whiteSpace: f.element.style.whiteSpace, 183 | display: f.element.style.display, 184 | fontSize: f.element.style.fontSize 185 | }; 186 | 187 | // should we observe DOM mutations 188 | observeMutations(f); 189 | 190 | // this is a new fitty so we need to validate if it's styles are in order 191 | f.newbie = true; 192 | 193 | // because it's a new fitty it should also be dirty, we want it to redraw on the first loop 194 | f.dirty = true; 195 | 196 | // we want to be able to update this fitty 197 | fitties.push(f); 198 | }; 199 | 200 | var destroy = function destroy(f) { 201 | return function () { 202 | 203 | // remove from fitties array 204 | fitties = fitties.filter(function (_) { 205 | return _.element !== f.element; 206 | }); 207 | 208 | // stop observing DOM 209 | if (f.observeMutations) f.observer.disconnect(); 210 | 211 | // reset the CSS properties we changes 212 | f.element.style.whiteSpace = f.originalStyle.whiteSpace; 213 | f.element.style.display = f.originalStyle.display; 214 | f.element.style.fontSize = f.originalStyle.fontSize; 215 | }; 216 | }; 217 | 218 | // add a new fitty, does not redraw said fitty 219 | var subscribe = function subscribe(f) { 220 | return function () { 221 | if (f.active) return; 222 | f.active = true; 223 | requestRedraw(); 224 | }; 225 | }; 226 | 227 | // remove an existing fitty 228 | var unsubscribe = function unsubscribe(f) { 229 | return function () { 230 | return f.active = false; 231 | }; 232 | }; 233 | 234 | var observeMutations = function observeMutations(f) { 235 | 236 | // no observing? 237 | if (!f.observeMutations) return; 238 | 239 | // start observing mutations 240 | f.observer = new MutationObserver(fit(f, DrawState.DIRTY_CONTENT)); 241 | 242 | // start observing 243 | f.observer.observe(f.element, f.observeMutations); 244 | }; 245 | 246 | // default mutation observer settings 247 | var mutationObserverDefaultSetting = { 248 | subtree: true, 249 | childList: true, 250 | characterData: true 251 | }; 252 | 253 | // default fitty options 254 | var defaultOptions = { 255 | minSize: 16, 256 | maxSize: 512, 257 | multiLine: true, 258 | observeMutations: 'MutationObserver' in w ? mutationObserverDefaultSetting : false 259 | }; 260 | 261 | // array of elements in, fitty instances out 262 | function fittyCreate(elements, options) { 263 | 264 | // set options object 265 | var fittyOptions = _extends({}, defaultOptions, options); 266 | 267 | // create fitties 268 | var publicFitties = elements.map(function (element) { 269 | 270 | // create fitty instance 271 | var f = _extends({}, fittyOptions, { 272 | 273 | // internal options for this fitty 274 | element: element, 275 | active: true 276 | }); 277 | 278 | // initialise this fitty 279 | init(f); 280 | 281 | // expose API 282 | return { 283 | element: element, 284 | fit: fit(f, DrawState.DIRTY), 285 | unfreeze: subscribe(f), 286 | freeze: unsubscribe(f), 287 | unsubscribe: destroy(f) 288 | }; 289 | }); 290 | 291 | // call redraw on newly initiated fitties 292 | requestRedraw(); 293 | 294 | // expose fitties 295 | return publicFitties; 296 | } 297 | 298 | // fitty creation function 299 | function fitty(target) { 300 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 301 | 302 | 303 | // if target is a string 304 | return typeof target === 'string' ? 305 | 306 | // treat it as a querySelector 307 | fittyCreate(toArray(document.querySelectorAll(target)), options) : 308 | 309 | // create single fitty 310 | fittyCreate([target], options)[0]; 311 | } 312 | 313 | // handles viewport changes, redraws all fitties, but only does so after a timeout 314 | var resizeDebounce = null; 315 | var onWindowResized = function onWindowResized() { 316 | w.clearTimeout(resizeDebounce); 317 | resizeDebounce = w.setTimeout(redrawAll(DrawState.DIRTY_LAYOUT), fitty.observeWindowDelay); 318 | }; 319 | 320 | // define observe window property, so when we set it to true or false events are automatically added and removed 321 | var events = ['resize', 'orientationchange']; 322 | Object.defineProperty(fitty, 'observeWindow', { 323 | set: function set(enabled) { 324 | var method = (enabled ? 'add' : 'remove') + 'EventListener'; 325 | events.forEach(function (e) { 326 | w[method](e, onWindowResized); 327 | }); 328 | } 329 | }); 330 | 331 | // fitty global properties (by setting observeWindow to true the events above get added) 332 | fitty.observeWindow = true; 333 | fitty.observeWindowDelay = 100; 334 | 335 | // public fit all method, will force redraw no matter what (on next animation frame) 336 | fitty.fitAll = redrawAll(DrawState.DIRTY); 337 | // public fit all method, will force redraw no matter what, NOW (for use in a requestAnimationFrame callback) 338 | fitty.fitAllImmediately = () => { 339 | redrawAll(DrawState.DIRTY); 340 | redrawNow(); 341 | }; 342 | 343 | // export our fitty function, we don't want to keep it to our selves 344 | return fitty; 345 | }(typeof window === 'undefined' ? null : window)); 346 | -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "True Random Movie", 3 | "short_name": "Random Movie", 4 | "description": "Spin the wheel to pick from 32K+ movies.", 5 | "categories": [ 6 | "utilities", 7 | "entertainment" 8 | ], 9 | "display": "fullscreen", 10 | "start_url": ".", 11 | "lang": "en-US", 12 | "theme_color": "#ff0000", 13 | "background_color": "#ffffff", 14 | "screenshots": [ 15 | { 16 | "src": "screenshot.png", 17 | "sizes": "870x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "icons": [ 22 | { 23 | "src": "icon.svg", 24 | "sizes": "512x512", 25 | "type": "image/svg+xml" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1j01/true-random-movie/1cde02e53084a0c7c70562a710625e6f1277a874/screenshot.png -------------------------------------------------------------------------------- /test-subtitles.txt: -------------------------------------------------------------------------------- 1 | One Piece Movie: The Desert Princess and the Pirates: Adventures in Alabasta (2007) 2 | Pokémon: Zoroark: Master of Illusions (2011) 3 | Yakuza's Law: Yakuza Keibatsushi: Rinchi (1969) 4 | One Piece Movie: The Desert Princess and the Pirates: Adventures in Alabasta (2007) 5 | Pokémon: Zoroark: Master of Illusions (2011) 6 | Yakuza's Law: Yakuza Keibatsushi: Rinchi (1969) 7 | One Piece Movie: The Desert Princess and the Pirates: Adventures in Alabasta (2007) 8 | Pokémon: Zoroark: Master of Illusions (2011) 9 | Yakuza's Law: Yakuza Keibatsushi: Rinchi (1969) 10 | 00 Schneider – Jagd auf Nihil Baxter (1994) 11 | The Ten Commandments: The Movie (2016) 12 | 11:14 (2003) 13 | 12 Rounds 2: Reloaded (2013) 14 | 12 Rounds 3: Lockdown (2015) 15 | 3:10 to Yuma (1957 & 2007) 16 | 44 Minutes: The North Hollywood Shoot-Out (2003) 17 | 64: Part I (2016) 18 | 64: Part II (2016) 19 | 7:35 in the Morning (2003) 20 | 8: The Mormon Proposition (2010) 21 | 7:35 in the Morning (2003) 22 | 8: The Mormon Proposition (2010) 23 | We Believe: Chicago and Its Cubs (2009) 24 | We Ride: The Story of Snowboarding (2013) 25 | We Steal Secrets: The Story of WikiLeaks (2013) 26 | The Weavers: Wasn't That a Time! (1982) 27 | Wendy Wu: Homecoming Warrior (2006) 28 | Title: Subtitle: The Subtitling: Ultrasubtitler: The Movie (42069) 29 | Alien vs. Predator (2004) 30 | Ballistic: Ecks vs. Sever (2002) 31 | Aliens vs. Predator: Requiem (2007) 32 | Batman v Superman: Dawn of Justice (2016) 33 | Batman Unlimited: Mechs vs. Mutants (2016) 34 | Scooby-Doo!: Abracadabra-Doo (2010) 35 | Scooby-Doo!: in Arabian Nights (1994 TV) 36 | Scooby-Doo: and the Alien Invaders (2000) 37 | Scooby-Doo! & Batman: The Brave and the Bold (2018) 38 | Scooby-Doo! and WWE: Curse of the Speed Demon (2016) 39 | Scooby-Doo: on Zombie Island (1998) 40 | Scooby-Doo! and Kiss: Rock and Roll Mystery (2015) 41 | Scooby-Doo: and the Cyber Chase (2001) 42 | Scooby-Doo!: and the Goblin King (2008) 43 | -------------------------------------------------------------------------------- /tick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1j01/true-random-movie/1cde02e53084a0c7c70562a710625e6f1277a874/tick.wav --------------------------------------------------------------------------------