├── .gitignore
├── README.md
├── app.js
├── assets
├── blank.png
├── fonts
│ ├── manrope-bold.woff2
│ ├── manrope-light.woff2
│ ├── manrope-medium.woff2
│ ├── manrope-semibold.woff2
│ └── manrope-thin.woff2
├── furniture.png
├── grid.png
├── grunge.png
├── icons
│ ├── app
│ │ ├── check.svg
│ │ ├── close.svg
│ │ ├── heart.svg
│ │ ├── left.svg
│ │ ├── list.svg
│ │ ├── play.svg
│ │ ├── right.svg
│ │ ├── search.svg
│ │ ├── settings.svg
│ │ ├── signout.svg
│ │ └── sync.svg
│ ├── trakt
│ │ ├── 128x128.png
│ │ ├── 512x512.png
│ │ ├── trakt.icns
│ │ ├── trakt.ico
│ │ ├── trakt.svg
│ │ └── trakt_no_circle.png
│ └── traktify
│ │ ├── 128x128.png
│ │ ├── 512x512.png
│ │ ├── traktify.icns
│ │ ├── traktify.ico
│ │ └── traktify.svg
├── loading_placeholder.gif
├── loading_placeholder_nobg.gif
├── master.css
├── placeholder.png
├── placeholder_nobg.png
└── previews
│ ├── blank.png
│ ├── furniture.png
│ ├── grid.png
│ └── grunge.png
├── config.json
├── def_config.json
├── docs
├── cache.md
├── config.md
├── episodes.md
└── helpers.md
├── modules
├── api
│ ├── cachers.js
│ ├── getters.js
│ └── requesters.js
├── cache.js
├── helper.js
├── presence.js
├── queue.js
├── request.js
└── rpc.js
├── package-lock.json
├── package.json
├── pages
├── dashboard
│ ├── index.html
│ ├── index.js
│ └── style.css
├── loading
│ ├── index.html
│ ├── index.js
│ └── style.css
├── login
│ ├── index.html
│ ├── index.js
│ └── style.css
└── main.js
└── prototype
└── traktify.xd
/.gitignore:
--------------------------------------------------------------------------------
1 | # system trash
2 | .DS_Store
3 | .stignore
4 | /**/.stfolder
5 |
6 | # unwanted trash
7 | *.bak
8 | *.log
9 | *.tmp
10 |
11 | /**/tmp
12 |
13 | # build folders
14 | /bin
15 | /out
16 | /target
17 |
18 | # node stuff
19 | /node_modules
20 | /packages
21 | logs
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # runtime stuff
27 | pids
28 | *.pid
29 | *.seed
30 | *.pid.lock
31 | .npm
32 |
33 | # secrets
34 | *.real.*
35 | .env
36 |
37 | # cache
38 | /**/.cache
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | > A multi platform desktop app for trakt.tv
8 |
9 | ![quality][quality]
10 | ![size][size]
11 | ![top_lang][top_lang]
12 | ![electron][electron]
13 |
14 | Traktify provides super easy super fast access to your trakt.tv account. User experience is our top priority.
15 |
16 | Visit the project's website [here](https://codingbobby.xyz/traktify).
17 |
18 |
19 | ## Features
20 | So let us tell you what traktify can do—because thats what you really care about right?
21 |
22 | ### Fast-access dashboard
23 | 
24 | Traktify's dashboard offers a clean overview of whats up next to watch for you. From here, you can add episodes you just watched to your history. The search panel allows blazing-fast access to the entire trakt database. Via shortcuts, filtering search results is made very easy.
25 |
26 | ### Keyboard shortcuts
27 | 
28 | If you're a keyboard orientated person, we've got you. Simple commands allow you to quickly jump through traktify's pages and panels.
29 |
30 | ### Customization
31 | 
32 | A wide range of settings let you customize the look and feel of your app. You can apply different accent colors, background textures and more.
33 |
34 | ### Discord integration
35 | If you wish, you can let traktify show a beautiful rich-presence on your Discord profile. All you need to do is clicking a button.
36 |
37 |
38 | ## Getting started
39 | Traktify is currently at it's climax of development. You can dive in really soon.
40 |
41 | ### Requirements
42 | Traktify is an electron based app and thus requires a Windows 7 (and higher) or macOS 10.10 (and higher) machine to run on. You'll also need a [Node.js](https://nodejs.org/en/download/) installation. Traktify is tested on versions higher than `v10.10.0` but we recommend the latest `LTS` release.
43 |
44 | If you want to use the Discord Rich-Presence integration, you'll also have to install [Python](https://www.python.org/downloads/).
45 |
46 |
47 | ## Contributing
48 | Contributions are very welcome! To report issues and start pull requests, please use gihub's integrated systems.
49 |
50 |
51 | ## Credits
52 | - [Jean van Kasteel](https://github.com/vankasteelj): `trakt.tv` and `fanart.tv`
53 | - [Roy Riojas](https://github.com/royriojas): `flat-cache`
54 |
55 |
56 | ## Authors
57 |
58 | ### [Bumbleboss](https://github.com/Bumbleboss)
59 | - Founder
60 | - Frontend developer
61 | - Graphics designer
62 |
63 | ### [CodingBobby](https://github.com/CodingBobby)
64 | - Cofounder
65 | - Fullstack developer
66 | - Project manager
67 |
68 |
69 |
70 | [top_lang]: https://img.shields.io/github/languages/top/CodingBobby/traktify.svg?style=flat-square
71 | [quality]: https://img.shields.io/codacy/grade/a68c06c191d54df0879b854c05c2ea79/master.svg?style=flat-square
72 | [electron]: https://img.shields.io/github/package-json/dependency-version/CodingBobby/traktify/dev/electron.svg?style=flat-square
73 | [size]: https://img.shields.io/github/repo-size/CodingBobby/traktify.svg?style=flat-square
74 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | /*\
2 | |*| TRAKTIFY
3 | |*| is a desktop app for trakt.tv
4 | |*| created by @CodingBobby and @Bumbleboss in 2019
5 | |*| using the trakt api with the trakt.js library
6 | |*| in an electron framework,
7 | |*| the current version is 0.1.1
8 | |*| from the 21th Feb. 2019
9 | \*/
10 |
11 | // Uncomment this line for publishing!
12 | // process.env.NODE_ENV = 'production'
13 |
14 | let initTime = Date.now()
15 |
16 | // electron stuff
17 | const electron = require('electron')
18 | const windowStateKeeper = require('electron-window-state')
19 | const {
20 | app,
21 | BrowserWindow,
22 | Menu,
23 | shell,
24 | clipboard,
25 | dialog,
26 | ipcMain
27 | } = electron
28 |
29 | // file stuff
30 | const fs = require('fs')
31 | const path = require('path')
32 | const rimraf = require('rimraf')
33 |
34 | // api stuff
35 | const Trakt = require('trakt.tv')
36 | const Fanart = require('fanart.tv')
37 | const TvDB = require('node-tvdb')
38 | const TmDB = require('moviedb-promise')
39 |
40 | // request stuff
41 | const request = require('request')
42 |
43 | const {
44 | debugLog, inRange, shadeHexColor, clone, ipcChannels
45 | } = require('./modules/helper.js')
46 | global.debugLog = debugLog
47 |
48 |
49 | // important checks regarding required files
50 | let fatalError = false
51 |
52 | fs.exists('./config.json', ex => {
53 | if(!ex) {
54 | debugLog('error', 'config does not exist', new Error().stack)
55 | fatalError = true
56 | }
57 | })
58 |
59 | if(process.env.trakt_id == undefined) envErr()
60 | if(process.env.trakt_secret == undefined) envErr()
61 | if(process.env.fanart_key == undefined) envErr()
62 | if(process.env.tmdb_key == undefined) envErr()
63 | if(process.env.tvdb_key == undefined) envErr()
64 | if(process.env.discord_key == undefined) envErr()
65 |
66 | function envErr() {
67 | debugLog('error', 'one or more env vars do not exist', new Error().stack)
68 | fatalError = true
69 | }
70 |
71 |
72 | // configuration and boolean checks that we need frequently
73 | // the config file will be used to save preferences the user can change
74 | // (like darkmode, behavior etc.)
75 | global.config = JSON.parse(fs.readFileSync("./config.json", "utf8"))
76 | let user = global.config.user
77 |
78 | // defining global variables that can be accessed from other scripts
79 | global.openExternal = shell.openExternal
80 | global.darwin = process.platform == 'darwin'
81 |
82 | // Comment out these lines when in production! Used as helpers to move around the app from the command line.
83 | global.loadDashboard = loadDashboard
84 | global.loadLogin = loadLogin
85 |
86 | // these are the api globals
87 | global.trakt
88 | global.fanart
89 | global.tvdb
90 | global.tmdb
91 |
92 | let window = null
93 |
94 | const traktOptions = {
95 | client_id: process.env.trakt_id,
96 | client_secret: process.env.trakt_secret
97 | }
98 |
99 | if(process.env.NODE_ENV !== 'production') {
100 | traktOptions.debug = true
101 | }
102 |
103 | // here we set some options we need later
104 | const windowOptions = {
105 | minWidth: 800,
106 | minHeight: 500,
107 | width: 900,
108 | height: 750,
109 | useContentSize: true,
110 | titleBarStyle: 'hidden',
111 | backgroundColor: '#242424',
112 | title: 'Traktify',
113 | icon: global.darwin ? path.join(__dirname, 'assets/icons/trakt/trakt.icns')
114 | : path.join(__dirname, 'assets/icons/trakt/tract.ico'),
115 | show: false,
116 | center: true,
117 | webPreferences: {
118 | experimentalFeatures: true
119 | }
120 | }
121 |
122 | // here we create a template for the main menu, to get the right shortcut, we check if we're running on darwin
123 | let menuTemplate = [{
124 | label: 'App',
125 | submenu: [{
126 | label: 'About',
127 | click() {
128 | shell.openExternal('https://github.com/CodingBobby/traktify')
129 | }
130 | }, {
131 | label: 'Quit Traktify',
132 | accelerator: global.darwin ? 'Command+Q'
133 | : 'Ctrl+Q',
134 | click() {
135 | app.quit()
136 | }
137 | }, {
138 | type: 'separator'
139 | }, {
140 | label: 'Reset Traktify',
141 | click() {
142 | dialog.showMessageBox({
143 | type: 'question',
144 | title: 'Reset Traktify',
145 | message: 'Are you sure? This removes all data from Traktify and you have to login again.',
146 | buttons: ['alright', 'hell no'],
147 | defaultId: 1,
148 | normalizeAccessKeys: false
149 | }, button => {
150 | if(button == 0) {
151 | resetTraktify(true)
152 | }
153 | })
154 | }
155 | }]
156 | }]
157 |
158 | // if the app is in development mode, these menu items will be pushed to the menu template
159 | if(process.env.NODE_ENV !== 'production') {
160 | menuTemplate.push({
161 | label: 'Dev Tools',
162 | submenu: [{
163 | label: 'Toggle Dev Tools',
164 | accelerator: global.darwin ? 'Command+I'
165 | : 'Ctrl+I',
166 | click(item, focusedWindow){
167 | focusedWindow.toggleDevTools()
168 | }
169 | }, {
170 | label: 'Reload App',
171 | accelerator: global.darwin ? 'Command+R'
172 | : 'Ctrl+R',
173 | role: 'reload'
174 | }]
175 | })
176 | }
177 |
178 | const mainMenu = Menu.buildFromTemplate(menuTemplate)
179 |
180 | // This function builds the app window, shows the correct page and handles window.on() events
181 | function build() {
182 | if(fatalError) {
183 | return process.crash()
184 | }
185 |
186 | debugLog('app', 'now building')
187 | let mainWindowState = windowStateKeeper({
188 | defaultWidth: 900,
189 | defaultHeight: 750
190 | })
191 |
192 | let settings = getSettings('app')
193 |
194 | if(settings['keep window state'].status) {
195 | debugLog('app', 'keeping window state changes')
196 | windowOptions.x = mainWindowState.x
197 | windowOptions.y = mainWindowState.y
198 | windowOptions.width = mainWindowState.width
199 | windowOptions.height = mainWindowState.height
200 | }
201 |
202 | if(settings['discord rpc'].status) {
203 | debugLog('app', 'discord rpc enabled')
204 | }
205 |
206 | window = new BrowserWindow(windowOptions)
207 | Menu.setApplicationMenu(mainMenu)
208 |
209 | if(getSettings('app')['keep window state'].status) {
210 | mainWindowState.manage(window)
211 | }
212 |
213 | // These now try to connect to the APIs we are using
214 | try {
215 | debugLog('api', 'creating trakt instance')
216 | global.trakt = new Trakt(traktOptions)
217 | } catch(err) {
218 | debugLog('error', 'trakt authentication', new Error().stack)
219 | }
220 |
221 | try {
222 | debugLog('api', 'creating fanart instance')
223 | global.fanart = new Fanart(process.env.fanart_key)
224 | } catch(err) {
225 | debugLog('error', 'fanart authentication', new Error().stack)
226 | }
227 |
228 | try {
229 | debugLog('api', 'creating tvdb instance')
230 | global.tvdb = new TvDB(process.env.tvdb_key)
231 | } catch(err) {
232 | debugLog('error', 'tvdb authentication', new Error().stack)
233 | }
234 |
235 | try {
236 | debugLog('api', 'creating tmdb instance')
237 | global.tmdb = new TmDB(process.env.tmdb_key)
238 | } catch(err) {
239 | debugLog('error', 'tmdb authentication', new Error().stack)
240 | }
241 |
242 |
243 | // show the window when the page is built
244 | window.once('ready-to-show', () => {
245 | debugLog('window', 'ready')
246 | window.show()
247 | })
248 |
249 | debugLog('init time', (Date.now() - initTime)+'ms')
250 |
251 | // Now we launch the app renderer
252 | launchApp()
253 |
254 |
255 | // EVENTS
256 |
257 | // if the window gets closed, the app will quit
258 | window.on('closed', () => {
259 | debugLog('window', 'closed')
260 | win = null
261 | })
262 |
263 | window.on('restore', () => {
264 | debugLog('window', 'restored')
265 | window.focus()
266 | })
267 | }
268 |
269 | // here we finally build the app
270 | app.on('ready', build)
271 |
272 | // this quits the whole app
273 | app.on('window-all-closed', () => {
274 | debugLog('app', 'now closing')
275 | app.quit()
276 | })
277 |
278 |
279 | // This launcher checks if the user is possibly logged in already. If so, we try to login with the existing credentials. If not, we go directly to the login screen.
280 | function launchApp() {
281 | if(user.trakt.auth) {
282 | debugLog('login', 'connecting existing user to trakt')
283 | tryLogin()
284 | } else {
285 | debugLog('login', 'no user found')
286 | loadLogin()
287 | }
288 | }
289 |
290 | function tryLogin() {
291 | // First, we show the loading screen to tell the user that something is happening. Eventually, when all loading processes are finished, it will be closed again to reveal the dashboard.
292 | loadLoadingScreen()
293 |
294 | // wait until loading screen is fully loaded
295 | ipcMain.once('loading-screen', (event, data) => {
296 | if(data === 'loaded') {
297 | debugLog('loading', 'can start now')
298 |
299 | global.trakt.import_token(user.trakt.auth).then(() => {
300 | global.trakt.refresh_token(user.trakt.auth).then(async newAuth => {
301 | user.trakt.auth = newAuth
302 | user.trakt.status = true
303 | saveConfig()
304 | debugLog('login', 'success')
305 |
306 | // track user stats for traktify analytics
307 | let userSettings = await trakt.users.settings().then(res => res)
308 | request(`https://traktify-server.herokuapp.com/stats?username=${userSettings.user.username}`, {
309 | json: true
310 | }, (err, res, body) => {
311 | debugLog('user authentications', body.data.requests)
312 | })
313 |
314 |
315 | event.returnValue = 'start'
316 |
317 | // After loadingHandler is finished with everything, the dashboard is opened
318 | loadingHandler().then(() => {
319 | loadDashboard()
320 | })
321 | }).catch(err => {
322 | if(err) {
323 | user.trakt.auth = false
324 | user.trakt.status = false
325 | saveConfig()
326 | debugLog('login failed', err)
327 | deleteCacheFolder()
328 | loadLogin()
329 | }
330 | })
331 | })
332 | }
333 | })
334 | }
335 |
336 | function authenticate() {
337 | return global.trakt.get_codes().then(poll => {
338 | clipboard.writeText(poll.user_code) // give the user the code
339 | global.codeToClipboard = function codeToClipboard() {
340 | // provides the user the option to get the code again
341 | clipboard.writeText(poll.user_code)
342 | }
343 | shell.openExternal(poll.verification_url)
344 |
345 | return global.trakt.poll_access(poll)
346 | }).then(auth => {
347 | debugLog('login', 'trakt user signed in')
348 | global.trakt.import_token(auth)
349 |
350 | user.trakt.auth = auth
351 | user.trakt.status = true
352 | saveConfig()
353 |
354 | // going back to the app and heading into dashboard
355 | window.focus()
356 | tryLogin() // confirm login credentials for extra safety and start loading
357 |
358 | return true
359 | }).catch(err => {
360 | // The failing login probably won't happen because the trakt login page would already throw the error. This exist just as a fallback.
361 | if(err) {
362 | debugLog('error', 'login failed')
363 | user.trakt.auth = false
364 | user.trakt.status = false
365 | saveConfig()
366 |
367 | window.focus()
368 | loadLogin()
369 | }
370 | })
371 | }
372 | global.authenticate = authenticate
373 |
374 | function disconnect() {
375 | global.trakt.revoke_token()
376 | user.trakt.auth = false
377 | user.trakt.status = false
378 | defaultAll('app')
379 | saveConfig()
380 | deleteCacheFolder()
381 | loadLogin()
382 | }
383 | global.disconnect = disconnect
384 |
385 | function deleteCacheFolder() {
386 | fs.exists('./.cache', ex => {
387 | if(ex) {
388 | rimraf('./.cache', () => {
389 | debugLog('cache', 'removed all files')
390 | })
391 | } else {
392 | debugLog('cache', 'not available')
393 | }
394 | })
395 | }
396 |
397 | // These functions do nothing but load a render page
398 | function loadLogin() {
399 | window.loadFile('pages/login/index.html')
400 | }
401 | function loadDashboard() {
402 | window.loadFile('pages/dashboard/index.html')
403 | }
404 | function loadLoadingScreen() {
405 | window.loadFile('pages/loading/index.html')
406 | }
407 |
408 | function loadingHandler() {
409 | let loadingTime = Date.now()
410 |
411 | return new Promise((resolve, reject) => {
412 | // waiting for the loading to be done
413 | ipcMain.once('loading-screen', (event, data) => {
414 | if(data === 'done') {
415 | debugLog('loading time', Date.now()-loadingTime+'ms')
416 | resolve()
417 | }
418 | })
419 | })
420 | }
421 |
422 | // this function can be called to save changes in the config file
423 | function saveConfig() {
424 | fs.writeFile("./config.json", JSON.stringify(global.config), err => {
425 | if(err) console.error(err)
426 | })
427 | }
428 |
429 | // Hard reset the app, deletes user accounts.
430 | function resetTraktify(removeLogin) {
431 | let userTemp = false
432 | if(removeLogin) {
433 | disconnect()
434 | } else {
435 | userTemp = clone(user)
436 | }
437 | global.config = JSON.parse(fs.readFileSync("./def_config.json", "utf8"))
438 | if(userTemp) {
439 | global.config.user = userTemp
440 | }
441 | saveConfig()
442 | }
443 |
444 |
445 | // The getSetting and setSetting functions are used by the settings panel to get and apply custom settings. defaultAll can reset these settings by replacing the current ones with those from the default file def_config.json
446 | function getSettings(scope) {
447 | let settings = global.config.client.settings
448 | if(settings.hasOwnProperty(scope)) {
449 | return settings[scope]
450 | } else {
451 | console.error('Invalid scope at getSetting()')
452 | }
453 | }
454 | global.getSettings = getSettings
455 |
456 | function setSetting(scope, settingOption, newStatus) {
457 | let settings = global.config.client.settings[scope]
458 | let setting = settings[settingOption]
459 |
460 | if(newStatus == 'default') {
461 | setting.status = setting.default
462 | } else {
463 | switch(setting.type) {
464 | case 'select': {
465 | if(setting.options.hasOwnProperty(newStatus)) {
466 | setting.status = newStatus
467 | }
468 | break
469 | }
470 | case 'range': {
471 | if(inRange(newStatus, setting.range)) {
472 | setting.status = newStatus
473 | }
474 | break
475 | }
476 | case 'toggle': {
477 | if(typeof newStatus == 'boolean') {
478 | setting.status = newStatus
479 | }
480 | break
481 | }
482 | default: { break }
483 | }
484 | }
485 |
486 | saveConfig()
487 | }
488 | global.setSetting = setSetting
489 |
490 | function defaultAll(scope) {
491 | let settings = getSettings(scope)
492 | for(let s in settings) {
493 | setSetting(scope, s, 'default')
494 | }
495 | }
496 | global.defaultAll = defaultAll
497 |
498 |
499 | // This applies the saved settings to the master css file. The currently loaded HTML must handle the incoming message via the proper IPC helpers.
500 | function updateApp() {
501 | let settings = getSettings('app')
502 | for(let s in settings) {
503 | debugLog('updating setting', s)
504 | let setting = settings[s]
505 | // these are only the settings that can be changed in realtime
506 | switch(s) {
507 | case 'accent color': {
508 | let value = setting.options[setting.status].value
509 | window.webContents.send('modify-root', {
510 | name: '--accent_color',
511 | value: value
512 | })
513 |
514 | let value_dark = shadeHexColor(value, -20)
515 | window.webContents.send('modify-root', {
516 | name: '--accent_color_d',
517 | value: value_dark
518 | })
519 |
520 | break
521 | }
522 | case 'background image': {
523 | let value = setting.options[setting.status].value
524 | window.webContents.send('modify-root', {
525 | name: '--background_image',
526 | value: `url('./${value}')`
527 | })
528 | break
529 | }
530 | case 'background opacity': {
531 | let value = setting.status
532 | window.webContents.send('modify-root', {
533 | name: '--background_opacity',
534 | value: value/100
535 | })
536 | break
537 | }
538 | default: { break }
539 | }
540 | }
541 | }
542 | global.updateApp = updateApp
543 |
544 | // Quits the app and reopens it automatically. This is used to apply settings which would interfer with this this app.js file.
545 | function relaunchApp() {
546 | app.relaunch()
547 | app.quit(0)
548 | }
549 | global.relaunchApp = relaunchApp
550 |
551 |
552 | //:::: CACHE Listener ::::\\
553 | const Cache = require('./modules/cache.js')
554 | const Queue = new(require('./modules/queue.js'))
555 |
556 | let keyList = {}
557 |
558 | // Instead of directly saving the cache within the request module right after changes were made, we put the saving action into a queue and also filter them to only run once each cycle.
559 | ipcMain.on('cache', (event, details) => {
560 | /** details:
561 | * name,
562 | * action,
563 | * ?data,
564 | * ?key
565 | */
566 | switch(details.action) {
567 | case 'save': {
568 | Queue.add(function() {
569 | const cache = new Cache(details.name)
570 | cache.save()
571 | }, { overwrite: true })
572 | break
573 | }
574 |
575 | case 'addKey': {
576 | if(!keyList.hasOwnProperty(details.name)) {
577 | // list wasn't used yet
578 | keyList[details.name] = {}
579 | }
580 | keyList[details.name][details.key] = details.data
581 | break
582 | }
583 |
584 | case 'saveKeys': {
585 | const cache = new Cache(details.name)
586 | if(!keyList.hasOwnProperty(details.name)) {
587 | // nothing was saved in the keylist
588 | debugLog('!caching', 'attempted keylist doesn\'t exist')
589 | break
590 | }
591 | for(let k in keyList[details.name]) {
592 | cache.setKey(k, keyList[details.name][k])
593 | }
594 | cache.save()
595 | break
596 | }
597 |
598 | case 'setKey': {
599 | Queue.add(function() {
600 | const cache = new Cache(details.name)
601 | cache.setKey(details.key, details.data)
602 | }, { overwrite: true })
603 | break
604 | }
605 | }
606 | })
607 |
608 |
609 | //:::: LOG LISTENER ::::\\
610 | ipcMain.on('log', (event, details) => {
611 | /** details:
612 | * action,
613 | * log
614 | */
615 | // using the helper here to make calls from the main possible as well
616 | ipcChannels['log'](details)
617 | })
618 |
--------------------------------------------------------------------------------
/assets/blank.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/blank.png
--------------------------------------------------------------------------------
/assets/fonts/manrope-bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-bold.woff2
--------------------------------------------------------------------------------
/assets/fonts/manrope-light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-light.woff2
--------------------------------------------------------------------------------
/assets/fonts/manrope-medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-medium.woff2
--------------------------------------------------------------------------------
/assets/fonts/manrope-semibold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-semibold.woff2
--------------------------------------------------------------------------------
/assets/fonts/manrope-thin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-thin.woff2
--------------------------------------------------------------------------------
/assets/furniture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/furniture.png
--------------------------------------------------------------------------------
/assets/grid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/grid.png
--------------------------------------------------------------------------------
/assets/grunge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/grunge.png
--------------------------------------------------------------------------------
/assets/icons/app/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/heart.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/assets/icons/app/left.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/list.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/right.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/signout.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/app/sync.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/icons/trakt/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/128x128.png
--------------------------------------------------------------------------------
/assets/icons/trakt/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/512x512.png
--------------------------------------------------------------------------------
/assets/icons/trakt/trakt.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/trakt.icns
--------------------------------------------------------------------------------
/assets/icons/trakt/trakt.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/trakt.ico
--------------------------------------------------------------------------------
/assets/icons/trakt/trakt.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
22 |
--------------------------------------------------------------------------------
/assets/icons/trakt/trakt_no_circle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/trakt_no_circle.png
--------------------------------------------------------------------------------
/assets/icons/traktify/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/128x128.png
--------------------------------------------------------------------------------
/assets/icons/traktify/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/512x512.png
--------------------------------------------------------------------------------
/assets/icons/traktify/traktify.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/traktify.icns
--------------------------------------------------------------------------------
/assets/icons/traktify/traktify.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/traktify.ico
--------------------------------------------------------------------------------
/assets/icons/traktify/traktify.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
332 |
--------------------------------------------------------------------------------
/assets/loading_placeholder.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/loading_placeholder.gif
--------------------------------------------------------------------------------
/assets/loading_placeholder_nobg.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/loading_placeholder_nobg.gif
--------------------------------------------------------------------------------
/assets/master.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --black: #242424;
3 | --black_d: #191919;
4 | --white: #FFF;
5 | --white_d: #CECECE;
6 | --gray: #696969;
7 | --side_dist: 30px;
8 |
9 | /* the following variables will be set by the renderer when the window loads, these settings prevent short flashing and double file loading */
10 | --accent_color: transparent;
11 | --accent_color_d: transparent;
12 | --background_image: none;
13 | --background_opacity : 1;
14 | }
15 |
16 | * {
17 | user-select: none;
18 | }
19 |
20 | *:focus {
21 | outline: none;
22 | }
23 | /*::::::::::::::::::::::::::::::::::::::::::::::: FONTS :::::::::::::::::::::::::::::::::::::::::::::::*/
24 | @font-face {
25 | font-family: 'manrope';
26 | font-weight: 200;
27 | src: url('./fonts/manrope-thin.woff2');
28 | }
29 | @font-face {
30 | font-family: 'manrope';
31 | font-weight: 300;
32 | src: url('./fonts/manrope-light.woff2');
33 | }
34 | @font-face {
35 | font-family: 'manrope';
36 | font-weight: 500;
37 | src: url('./fonts/manrope-medium.woff2');
38 | }
39 | @font-face {
40 | font-family: 'manrope';
41 | font-weight: 600;
42 | src: url('./fonts/manrope-semibold.woff2');
43 | }
44 | @font-face {
45 | font-family: 'manrope';
46 | font-weight: 700;
47 | src: url('./fonts/manrope-bold.woff2');
48 | }
49 | /*::::::::::::::::::::::::::::::::::::::::::::::: TYPOGRAPHY :::::::::::::::::::::::::::::::::::::::::::::::*/
50 | .h1 {
51 | font: 600 32px manrope;
52 | }
53 | .h2 {
54 | font: 600 27px manrope;
55 | }
56 | .h3 {
57 | font: 500 23px manrope;
58 | }
59 | .p {
60 | font: 200 18px manrope;
61 | }
62 |
63 | .fw200 {
64 | font-weight: 200;
65 | }
66 | .fw500 {
67 | font-weight: 500;
68 | }
69 | .fw600 {
70 | font-weight: 600;
71 | }
72 | .fw700 {
73 | font-weight: 700;
74 | }
75 |
76 | .fs23 {
77 | font-size: 23px;
78 | }
79 | .fs18 {
80 | font-size: 18px;
81 | }
82 | .fs16 {
83 | font-size: 16px;
84 | }
85 | .fs14 {
86 | font-size: 14.5px;
87 | line-height: 20.5px;
88 | }
89 | .fs12 {
90 | font-size: 12px;
91 | }
92 |
93 | .tu {
94 | text-transform: uppercase;
95 | }
96 | .t_ {
97 | text-decoration: underline;
98 | }
99 | .tOverflow {
100 | overflow: hidden;
101 | text-overflow: ellipsis;
102 | white-space: nowrap;
103 | }
104 |
105 | .tOverflow.normal {
106 | white-space: normal;
107 | }
108 | /*::::::::::::::::::::::::::::::::::::::::::::::: POSITIONING :::::::::::::::::::::::::::::::::::::::::::::::*/
109 | .top,
110 | .top_p,
111 | .bottom,
112 | .bottom_p,
113 | .right,
114 | .left {
115 | position: fixed;
116 | }
117 |
118 | .center {
119 | position: relative;
120 | height: 100%;
121 | }
122 |
123 | .center * {
124 | overflow: auto;
125 | margin: auto;
126 | position: absolute;
127 | top: 0;
128 | left: 0;
129 | bottom: 0;
130 | right: 0;
131 | /* set height property to the individual element */
132 | }
133 |
134 | .top {
135 | top: 0;
136 | }
137 | .bottom {
138 | bottom: 0;
139 | }
140 | .right {
141 | right: var(--side_dist);
142 | }
143 | .left {
144 | left: var(--side_dist);
145 | }
146 |
147 | .top_p {
148 | top: var(--side_dist);
149 | }
150 | .bottom_p {
151 | bottom: var(--side_dist);
152 | }
153 |
154 | .z1 {
155 | z-index: 1;
156 | }
157 | .z2 {
158 | z-index: 2;
159 | }
160 | .z3 {
161 | z-index: 3;
162 | }
163 | .z4 {
164 | z-index: 4;
165 | }
166 | /*::::::::::::::::::::::::::::::::::::::::::::::: COLORS :::::::::::::::::::::::::::::::::::::::::::::::*/
167 | .black_t {
168 | color: var(--black);
169 | }
170 | .black_d_t {
171 | color: var(--black_d);
172 | }
173 | .white_t {
174 | color: var(--white);
175 | }
176 | .white_d_t {
177 | color: var(--white_d);
178 | }
179 | .red_t {
180 | color: var(--accent_color);
181 | }
182 | .red_d_t {
183 | color: var(--accent_color_d);
184 | }
185 |
186 | .black_b {
187 | background-color: var(--black);
188 | }
189 | .black_d_b {
190 | background-color: var(--black_d);
191 | }
192 | .white_b {
193 | background-color: var(--white);
194 | }
195 | .white_d_b {
196 | background-color: var(--white_d);
197 | }
198 | .red_b {
199 | background-color: var(--accent_color);
200 | }
201 | .red_d_b {
202 | background-color: var(--accent_color_d);
203 | }
204 | /*::::::::::::::::::::::::::::::::::::::::::::::: ATTRIBUTES :::::::::::::::::::::::::::::::::::::::::::::::*/
205 | .shadow_h {
206 | box-shadow: 0px 0px 34px rgba(0, 0, 0, 0.3);
207 | }
208 | .shadow_b {
209 | box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.5)
210 | }
211 |
212 | .vertical_border {
213 | background-color: var(--white);
214 | opacity: 0.06;
215 | width: 1px;
216 | height: 100%;
217 | }
218 | /*::::::::::::::::::::::::::::::::::::::::::::::: COMPONENTS :::::::::::::::::::::::::::::::::::::::::::::::*/
219 | body {
220 | margin: 0;
221 | font-family: manrope!important;
222 | overflow: hidden;
223 | background: var(--black_d);
224 | user-select: none;
225 | }
226 |
227 | body::after {
228 | content: '';
229 | width: 100%;
230 | height: 100%;
231 | position: absolute;
232 | top: 0;
233 | left: 0;
234 | z-index: -1;
235 | background: var(--background_image) repeat;
236 | opacity: var(--background_opacity);
237 | }
238 |
239 | #dragger {
240 | position: absolute;
241 | z-index: 10;
242 | width: 100vw;
243 | height: 20px;
244 | -webkit-app-region: drag;
245 | }
246 |
247 | .wrapper {
248 | position: relative;
249 | }
250 | .wrapper.flex {
251 | display: flex;
252 | min-height: calc(100vh - 60px);
253 | min-width: calc(100vw - 60px);
254 | margin: auto;
255 | }
256 | /*::::::::::::::::::::::: LOADING :::::::::::::::::::::::*/
257 |
258 | .loading-animation {
259 | background: linear-gradient(to right, var(--accent_color), var(--accent_color_d), var(--accent_color));
260 | background-size: 600% 600%;
261 | animation: gradient-animation 3s ease infinite;
262 | }
263 |
264 | @keyframes gradient-animation {
265 | 0%{background-position:100% 0%}
266 | 75%{background-position:0% 50%}
267 | 100%{backround-position:100% 0%}
268 | }
269 |
270 | .gray-animation {
271 | animation: gray-animation 2s ease infinite;
272 | }
273 |
274 | @keyframes gray-animation {
275 | 0%{filter:grayscale(0%)}
276 | 75%{filter:grayscale(50%)}
277 | 100%{filter:grayscale(0%)}
278 | }
279 | /*::::::::::::::::::::::: SCROLLERS :::::::::::::::::::::::*/
280 | ::-webkit-scrollbar {
281 | background-color: transparent;
282 | width: 13px;
283 | }
284 | ::-webkit-scrollbar-thumb {
285 | background-color: var(--black_d);
286 | background-clip: padding-box;
287 | border: solid transparent 3px;
288 | border-radius: 30px;
289 | }
290 |
291 | .slider {
292 | width: 100%;
293 | height: 5px;
294 | border-radius: 5px;
295 | outline: none;
296 | -webkit-appearance: none;
297 | }
298 | .slider::-webkit-slider-thumb {
299 | appearance: none;
300 | width: 20px;
301 | height: 20px;
302 | background: var(--accent_color_d);
303 | border-radius: 50%;
304 | -webkit-appearance: none;
305 | cursor: pointer;
306 | }
307 | /*::::::::::::::::::::::: BUTTONS :::::::::::::::::::::::*/
308 | .btns {
309 | list-style: none;
310 | padding: 0;
311 | margin: 0;
312 | }
313 | .btns .btn {
314 | margin-bottom: 8px;
315 | }
316 |
317 | .btn {
318 | padding: 10px 30px;
319 | border-radius: 20px;
320 | cursor: pointer;
321 | transition: all 200ms ease;
322 | display: block;
323 | }
324 | .btn:hover {
325 | filter: grayscale(20%);
326 | }
327 | .btn.selected {
328 | background-color: var(--accent_color);
329 | }
330 | .btn.rotating {
331 | animation-name: rotate;
332 | animation-duration: 0.8s;
333 | animation-iteration-count: infinite;
334 | }
335 | @keyframes rotate {
336 | from {transform: rotate(0deg);}
337 | to {transform: rotate(360deg);}
338 | }
339 | .btn.bottom {
340 | padding: 10px 19px;
341 | border-radius: 20px 20px 0 0;
342 | }
343 |
344 | .btn.icon {
345 | border-radius: 50%;
346 | width: min-content;
347 | padding: 12px;
348 | display: flex;
349 | }
350 | .btn.icon_small {
351 | border-radius: 50%;
352 | padding: 4px;
353 | display: flex;
354 | height: min-content;
355 | width: min-content;
356 | }
357 |
358 | .btn.icon img {
359 | filter: invert(1);
360 | width: 20px;
361 | height: 20px;
362 | vertical-align: middle;
363 | }
364 | .btn.icon_small img {
365 | filter: invert(1);
366 | width: 12px;
367 | height: 12px;
368 | vertical-align: middle;
369 | }
370 |
371 | .btn-switch {
372 | font-size: 10px;
373 | line-height: 14.5px;
374 | position: relative;
375 | display: inline-block;
376 | }
377 | .btn-switch__radio {
378 | display: none;
379 | }
380 | .btn-switch__label {
381 | display: inline-block;
382 | padding: 0.6em 0.75em 1em 0.5em;
383 | cursor: pointer;
384 | transition: color 200ms ease;
385 | }
386 | .btn-switch__txt {
387 | position: relative;
388 | z-index: 2;
389 | display: inline-block;
390 | min-width: 18px;
391 | opacity: 1;
392 | pointer-events: none;
393 | transition: opacity 200ms ease;
394 | }
395 | .btn-switch__label:before {
396 | content: "";
397 | position: absolute;
398 | z-index: -1;
399 | top: 0;
400 | right: 0;
401 | bottom: 0;
402 | left: 0;
403 | background: var(--accent_color);
404 | border-radius: 1.5em;
405 | box-shadow: inset 0 0.0715em 0.3572em rgba(43, 43, 43, 0.05);
406 | transition: background 200ms ease;
407 | }
408 | .btn-switch__radio_no:checked ~ .btn-switch__label:before {
409 | background: var(--gray);
410 | }
411 | .btn-switch__label_no:after {
412 | content: "";
413 | position: absolute;
414 | z-index: 2;
415 | top: 0.5em;
416 | bottom: 0.5em;
417 | left: 0.5em;
418 | width: 2em;
419 | background: var(--white);
420 | border-radius: 1em;
421 | pointer-events: none;
422 | box-shadow: 0 0.1429em 0.2143em rgba(43, 43, 43, 0.2),
423 | 0 0.3572em 0.3572em rgba(43, 43, 43, 0.1);
424 | transition: left 200ms ease, background 200ms ease;
425 | }
426 | .btn-switch__radio_yes:checked ~ .btn-switch__label_no:after {
427 | left: calc(100% - 2.5em);
428 | background: var(--white);
429 | }
430 | .btn-switch__radio_no:checked ~ .btn-switch__label_yes:before,
431 | .btn-switch__radio_yes:checked ~ .btn-switch__label_no:before {
432 | z-index: 1;
433 | }
434 | .btn-switch__radio_no:checked ~ .btn-switch__label_yes {
435 | color: var(--white);
436 | }
437 | /*::::::::::::::::::::::: OVERLAY :::::::::::::::::::::::*/
438 | .overlay {
439 | left: 0;
440 | height: 100vh;
441 | width: 100vw;
442 | display: none;
443 |
444 | overflow: hidden;
445 | background-color: var(--black_d);
446 |
447 | opacity: 0.75;
448 | animation: blend_out 150ms cubic-bezier(.165,.84,.44,1) 1 forwards;
449 | }
450 | .overlay.show {
451 | display: block!important;
452 | animation: blend_in 150ms cubic-bezier(.165,.84,.44,1) 1 forwards!important;
453 | }
454 | /*::::::::::::::::::::::: POSTERS :::::::::::::::::::::::*/
455 | .posters {
456 | list-style: none;
457 | padding: 0;
458 | margin: 0;
459 | }
460 |
461 | .poster {
462 | position: relative;
463 | width: 140px;
464 | height: 205px;
465 | margin-right: 20px;
466 | display: inline-block;
467 | transition: all 400ms ease;
468 | }
469 |
470 | .poster > img {
471 | object-fit: cover;
472 | width: 100%;
473 | height: 100%;
474 | animation: fade_in 0.1s;
475 | }
476 | @keyframes fade_in {
477 | 0% {
478 | opacity: 0;
479 | }
480 | 100% {
481 | opacity: 1;
482 | }
483 | }
484 |
485 | .poster,
486 | .poster img {
487 | border-radius: 5px;
488 | }
489 | /*:::::::::::::::::::::::::::::::::::::::::::::::: ACTION-BUTTTONS :::::::::::::::::::::::::::::::::::::::::::::::*/
490 | .beta_action_btns {
491 | margin: 30px 0;
492 | width: 100%;
493 | }
494 |
495 | .beta_action_btn {
496 | text-align: center;
497 | padding: 5px 25px;
498 | border-radius: 4px;
499 | margin: 15px 0;
500 | color: var(--white);
501 | cursor: pointer;
502 | transition: opacity 200ms ease;
503 | }
504 |
505 | .beta_action_btn:hover {
506 | opacity: .8;
507 | }
508 |
509 | .beta_action_btn * {
510 | vertical-align: middle;
511 | display: inline-block;
512 | }
513 |
514 | .beta_action_btn > img {
515 | width: 16px;
516 | height: 19px;
517 | margin-right: 10px;
518 | }
519 |
520 | .beta_action_btn.play {
521 | background-color: #ED1C24;
522 | }
523 | .beta_action_btn.play::after {
524 | content: 'Check in?'
525 | }
526 |
527 | .beta_action_btn.watchlist {
528 | background-color: #3796D5;
529 | }
530 | .beta_action_btn.watchlist::after {
531 | content: 'Watchlist'
532 | }
533 |
534 | .beta_action_btn.watched {
535 | background-color: #BD57E8;
536 | }
537 | .beta_action_btn.watched::after {
538 | content: 'Watched?'
539 | }
540 | /*::::::::::::::::::::::::::::::::::::::::::::::: ANIMATIONS :::::::::::::::::::::::::::::::::::::::::::::::*/
541 | .animation_slide_up {
542 | animation: slide_up 500ms cubic-bezier(.165,.84,.44,1) 1 forwards;
543 | }
544 | @keyframes slide_up {
545 | 0% {opacity:0;transform:translateY(50px)}
546 | 50% {opacity:0;transform:translateY(25px)}
547 | 100% {opacity:1;transform:translateY(0)}
548 | }
549 |
550 | .animation_slide_right {
551 | animation: slide_right 500ms cubic-bezier(.165,.84,.44,1) 1 forwards;
552 | }
553 | @keyframes slide_right {
554 | 0% {opacity:0;transform:translateX(30px)}
555 | 50% {opacity:0;transform:translateX(15px)}
556 | 100% {opacity:1;transform:translateX(0)}
557 | }
558 |
559 | .animation_blend_in {
560 | animation: blend_in 150ms cubic-bezier(.165,.84,.44,1) 1 forwards;
561 | }
562 | @keyframes blend_in {
563 | 0% {opacity:0;backdrop-filter:blur(0px)}
564 | 50% {opacity:0;backdrop-filter:blur(4px)}
565 | 100% {opacity:0.75;backdrop-filter:blur(8px)}
566 | }
567 |
568 | .animation_blend_out {
569 | animation: blend_out 150ms cubic-bezier(.165,.84,.44,1) 1 forwards;
570 | }
571 | @keyframes blend_out {
572 | 0% {opacity: 0.75;backdrop-filter: blur(8px)}
573 | 50% {opacity:0;backdrop-filter:blur(4px)}
574 | 100% {opacity:0;backdrop-filter: blur(0px)}
575 | }
576 |
577 | .animation_fade_out {
578 | animation: fade_out 1.2s cubic-bezier(.165,.84,.44,1) 1 forwards;
579 | }
580 | @keyframes fade_out {
581 | 0% {opacity:1;transform:translateY(0)}
582 | 50% {opacity:0;transform:translateY(50px)}
583 | 100% {opacity:0;transform:translateY(100px)}
584 | }
585 | /*::::::::::::::::::::::::::::::::::::::::::::::: RESPONSIVE :::::::::::::::::::::::::::::::::::::::::::::::*/
586 | @media screen and (max-width: 952px) {
587 | :root {
588 | --side_dist: 20px;
589 | }
590 |
591 | .h1 {
592 | font-size: 27px;
593 | }
594 | .h2 {
595 | font-size: 23px;
596 | }
597 | .h3 {
598 | font-size: 18px;
599 | }
600 | .p {
601 | font-size: 16px;
602 | }
603 |
604 | .wrapper.flex {
605 | min-height: calc(100vh - 40px);
606 | min-width: calc(100vw - 40px);
607 | }
608 | }
609 |
--------------------------------------------------------------------------------
/assets/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/placeholder.png
--------------------------------------------------------------------------------
/assets/placeholder_nobg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/placeholder_nobg.png
--------------------------------------------------------------------------------
/assets/previews/blank.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/blank.png
--------------------------------------------------------------------------------
/assets/previews/furniture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/furniture.png
--------------------------------------------------------------------------------
/assets/previews/grid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/grid.png
--------------------------------------------------------------------------------
/assets/previews/grunge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/grunge.png
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "client": {
3 | "settings": {
4 | "app": {
5 | "accent color": {
6 | "type": "select",
7 | "status": "red",
8 | "default": "red",
9 | "options": {
10 | "red": {
11 | "value": "#ED1C24"
12 | },
13 | "brown": {
14 | "value": "#A88E79"
15 | },
16 | "yellow": {
17 | "value": "#FF980D"
18 | },
19 | "green": {
20 | "value": "#0FD67C"
21 | },
22 | "violet": {
23 | "value": "#610BEA"
24 | },
25 | "blue": {
26 | "value": "#04BA9C"
27 | }
28 | },
29 | "needsReload": false
30 | },
31 | "background image": {
32 | "type": "select",
33 | "status": "furniture",
34 | "default": "furniture",
35 | "options": {
36 | "furniture": {
37 | "value": "furniture.png",
38 | "preview": true
39 | },
40 | "grid": {
41 | "value": "grid.png",
42 | "preview": true
43 | },
44 | "grunge": {
45 | "value": "grunge.png",
46 | "preview": true
47 | },
48 | "blank": {
49 | "value": "blank.png",
50 | "preview": true
51 | }
52 | },
53 | "needsReload": false
54 | },
55 | "background opacity": {
56 | "type": "range",
57 | "status": 100,
58 | "default": 100,
59 | "accuracy": 5,
60 | "range": [
61 | 0,
62 | 100
63 | ],
64 | "needsReload": false
65 | },
66 | "keep window state": {
67 | "type": "toggle",
68 | "status": false,
69 | "default": false,
70 | "needsReload": true
71 | },
72 | "discord rpc": {
73 | "type": "toggle",
74 | "status": false,
75 | "default": false,
76 | "needsReload": true
77 | }
78 | }
79 | },
80 | "cache": {
81 | "path": "../.cache/",
82 | "expires": 0
83 | },
84 | "placeholder": {
85 | "poster": "placeholder.png",
86 | "search": "placeholder_nobg.png"
87 | },
88 | "rpc": {
89 | "states": [
90 | "Frankly, my dear, I don't give a damn.",
91 | "Here's looking at you, kid.",
92 | "You're gonna need a bigger boat.",
93 | "May the Force be with you.",
94 | "Toto, I've a feeling we're not in Kansas anymore.",
95 | "I'm going to make him an offer he can't refuse.",
96 | "You talkin' to me?",
97 | "There's no place like home.",
98 | "I am your father.",
99 | "Why so serious?",
100 | "I'll have what she's having.",
101 | "We'll always have Paris.",
102 | "Bond. James Bond.",
103 | "I see dead people.",
104 | "I'll be back.",
105 | "You can't handle the truth!",
106 | "E.T. phone home.",
107 | "Yippie-ki-yay, motherf—er!",
108 | "To infinity and beyond!",
109 | "Houston, we have a problem.",
110 | "You had me at hello.",
111 | "There's no crying in baseball!",
112 | "Here's Johnny!",
113 | "I am serious. And don't call me Shirley.",
114 | "Carpe diem. Seize the day, boys.",
115 | "Leave the gun. Take the cannoli.",
116 | "Show me the money!",
117 | "Say hello to my little friend!",
118 | "Shaken, not stirred.",
119 | "I'm the king of the world!",
120 | "Mama says, 'Stupid is as stupid does.'",
121 | "Just keep swimming.",
122 | "If you build it, he will come.",
123 | "I'm not bad. I'm just drawn that way.",
124 | "I'm having an old friend for dinner.",
125 | "Hasta la vista, baby.",
126 | "The Dude abides.",
127 | "Stella! Hey, Stella!",
128 | "After all, tomorrow is another day!",
129 | "Help me, Obi-Wan Kenobi.",
130 | "Go ahead, make my day.",
131 | "It's alive! It's alive!",
132 | "Argo f— yourself.",
133 | "My precious.",
134 | "Good morning, Vietnam!",
135 | "I wish I knew how to quit you.",
136 | "That'll do, pig. That'll do.",
137 | "Elementary, my dear Watson.",
138 | "You ain't heard nothin' yet!",
139 | "Wax on, wax off.",
140 | "Yo, Adrian!",
141 | "Nobody's perfect.",
142 | "They call it a Royale with cheese.",
143 | "It was Beauty killed the Beast.",
144 | "I bought the airline. It seemed neater!"
145 | ]
146 | }
147 | },
148 | "user": {
149 | "trakt": {
150 | "auth": false,
151 | "status": false
152 | }
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/def_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "client": {
3 | "settings": {
4 | "app": {
5 | "accent color": {
6 | "type": "select",
7 | "status": "red",
8 | "default": "red",
9 | "options": {
10 | "red": {
11 | "value": "#ED1C24"
12 | },
13 | "brown": {
14 | "value": "#A88E79"
15 | },
16 | "yellow": {
17 | "value": "#FF980D"
18 | },
19 | "green": {
20 | "value": "#0FD67C"
21 | },
22 | "violet": {
23 | "value": "#610BEA"
24 | },
25 | "blue": {
26 | "value": "#04BA9C"
27 | }
28 | },
29 | "needsReload": false
30 | },
31 | "background image": {
32 | "type": "select",
33 | "status": "furniture",
34 | "default": "furniture",
35 | "options": {
36 | "furniture": {
37 | "value": "furniture.png",
38 | "preview": true
39 | },
40 | "grid": {
41 | "value": "grid.png",
42 | "preview": true
43 | },
44 | "grunge": {
45 | "value": "grunge.png",
46 | "preview": true
47 | },
48 | "blank": {
49 | "value": "blank.png",
50 | "preview": true
51 | }
52 | },
53 | "needsReload": false
54 | },
55 | "background opacity": {
56 | "type": "range",
57 | "status": 100,
58 | "default": 100,
59 | "accuracy": 5,
60 | "range": [
61 | 0,
62 | 100
63 | ],
64 | "needsReload": false
65 | },
66 | "keep window state": {
67 | "type": "toggle",
68 | "status": false,
69 | "default": false,
70 | "needsReload": true
71 | },
72 | "discord rpc": {
73 | "type": "toggle",
74 | "status": false,
75 | "default": false,
76 | "needsReload": true
77 | }
78 | }
79 | },
80 | "cache": {
81 | "path": "../.cache/",
82 | "expires": 0
83 | },
84 | "placeholder": {
85 | "poster": "placeholder.png",
86 | "search": "placeholder_nobg.png"
87 | },
88 | "rpc": {
89 | "states": [
90 | "Frankly, my dear, I don't give a damn.",
91 | "Here's looking at you, kid.",
92 | "You're gonna need a bigger boat.",
93 | "May the Force be with you.",
94 | "Toto, I've a feeling we're not in Kansas anymore.",
95 | "I'm going to make him an offer he can't refuse.",
96 | "You talkin' to me?",
97 | "There's no place like home.",
98 | "I am your father.",
99 | "Why so serious?",
100 | "I'll have what she's having.",
101 | "We'll always have Paris.",
102 | "Bond. James Bond.",
103 | "I see dead people.",
104 | "I'll be back.",
105 | "You can't handle the truth!",
106 | "E.T. phone home.",
107 | "Yippie-ki-yay, motherf—er!",
108 | "To infinity and beyond!",
109 | "Houston, we have a problem.",
110 | "You had me at hello.",
111 | "There's no crying in baseball!",
112 | "Here's Johnny!",
113 | "I am serious. And don't call me Shirley.",
114 | "Carpe diem. Seize the day, boys.",
115 | "Leave the gun. Take the cannoli.",
116 | "Show me the money!",
117 | "Say hello to my little friend!",
118 | "Shaken, not stirred.",
119 | "I'm the king of the world!",
120 | "Mama says, 'Stupid is as stupid does.'",
121 | "Just keep swimming.",
122 | "If you build it, he will come.",
123 | "I'm not bad. I'm just drawn that way.",
124 | "I'm having an old friend for dinner.",
125 | "Hasta la vista, baby.",
126 | "The Dude abides.",
127 | "Stella! Hey, Stella!",
128 | "After all, tomorrow is another day!",
129 | "Help me, Obi-Wan Kenobi.",
130 | "Go ahead, make my day.",
131 | "It's alive! It's alive!",
132 | "Argo f— yourself.",
133 | "My precious.",
134 | "Good morning, Vietnam!",
135 | "I wish I knew how to quit you.",
136 | "That'll do, pig. That'll do.",
137 | "Elementary, my dear Watson.",
138 | "You ain't heard nothin' yet!",
139 | "Wax on, wax off.",
140 | "Yo, Adrian!",
141 | "Nobody's perfect.",
142 | "They call it a Royale with cheese.",
143 | "It was Beauty killed the Beast.",
144 | "I bought the airline. It seemed neater!"
145 | ]
146 | }
147 | },
148 | "user": {
149 | "trakt": {
150 | "auth": false,
151 | "status": false
152 | }
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/docs/cache.md:
--------------------------------------------------------------------------------
1 | # Cache
2 |
3 | ## Workflow
4 | The standard procedure of getting API data. Before starting time intensive API requests, we check if the cache already holds what we want.
5 |
6 | ```js
7 | const cacheName = new Cache('cacheName')
8 |
9 | function getApiData() {
10 | let cacheContent = cacheName.getKey('apiData')
11 |
12 | if(cacheContent === undefined) {
13 | return requestApiData().then(apiData => {
14 | debugLog('caching', 'apiData')
15 | let cachingTime = Date.now()
16 |
17 | cacheName.setKey('apiData', apiData)
18 | cacheName.save()
19 |
20 | debugLog('caching time', Date.now()-cachingTime)
21 | return apiData
22 | })
23 | } else {
24 | debugLog('cache available', 'apiData')
25 | return cacheContent
26 | }
27 | }
28 | ```
29 |
--------------------------------------------------------------------------------
/docs/config.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 | The configuration file has the following structure:
3 | ```cson
4 | config:
5 | client:
6 | settings:
7 | app
8 | user
9 | user:
10 | trakt:
11 | auth
12 | status
13 | ```
14 |
15 | ## Settings
16 | There are two different scopes available: `app` and `user`.
17 |
18 | ### App
19 | The `app` scope contains settings of different types. All types share these properties:
20 |
21 | property | info
22 | ---|---
23 | type | Type of the setting
24 | status | The currently set value
25 | default | The default setting
26 | needsReload | Whether the app has to reload to apply changes
27 |
28 | The `select` type allows to choose one option from a list. Each option hast a name and a value.
29 | ```cson
30 | setting:
31 | type: 'select'
32 | status: name
33 | default: name
34 | options: {
35 | name: any
36 | name: any
37 | ...
38 | }
39 | needsReload: boolean
40 | ```
41 |
42 | The `toggle` type can switch between `true` and `false`.
43 | ```cson
44 | setting:
45 | type: 'toggle'
46 | status: boolean
47 | default: boolean
48 | needsReload: boolean
49 | ```
50 |
51 | The `range` type has a minimum and maximum possible value. The accuracy is defined by the `accuracy` property.
52 | ```cson
53 | setting:
54 | type: 'range'
55 | status: number
56 | default: number
57 | accuracy: number
58 | range: [number, number]
59 | needsReload: boolean
60 | ```
61 |
62 | ### User
63 | This scope is not in use yet.
64 |
--------------------------------------------------------------------------------
/docs/episodes.md:
--------------------------------------------------------------------------------
1 | # Episodes
2 |
3 | ## Request Structure
4 | Fetching a list of all episodes a show contains requires some workflow as it can't be done in one single step. This first seems like a problem but later helps to improve the requesting-caching-system.
5 |
6 | In the following, the request tree is shown. You can see that retrieving extended data is possible when requesting a list of seasons but not for a list of episodes, where a separate request for each episode is required.
7 |
8 | ```
9 | shows/${show_id}/seasons -> season[]
10 | season: { ?extended=full
11 | number,
12 | ids: { trakt, tvdb, tmdb },
13 | ...
14 | }
15 |
16 | shows/${show_id}/seasons/${season.number} -> episode[]
17 | episode: {
18 | season,
19 | number,
20 | title,
21 | ids: { trakt, tvdb, tmdb }
22 | }
23 |
24 | shows/${show_id}/seasons/${season.number}/episodes/${episode.number} -> episodeData
25 | episodeData: { ?extended=full
26 | ...
27 | }
28 | ```
29 |
30 | Lets say, this is the structure of a show that I just came up with:
31 |
32 | ```
33 | show:
34 | season 1:
35 | episode 1
36 | episode 2
37 | season 2:
38 | episode 3
39 | episode 5
40 | episode 6
41 | ```
42 |
43 | Then you would need 9 total requests to get all available data of this exact data structure—one for the list of seasons, two for the lists of episodes of each of its seasons and finally six for each of the episodes these lists contain.
44 |
45 | Unfortunately it is not possible to predict how many requests will be required in total as the general show info would only provide the amount of episodes and not the amount of seasons. However, with the first request that provides a list of seasons, you can calculate just that. In the following pseudo-code, `show` is equivalent to the result `season[]` of the first request shown in the block above. Thus, `totalRequests` does not include this required first request into the calculation.
46 |
47 | ```
48 | seasons = show.length
49 | episodes = 0
50 |
51 | for season in show:
52 | episodes += season.episode_count
53 |
54 | totalRequests = seasons + episodes
55 | ```
56 |
57 | ## Card Slider
58 | The info-card feature of traktify is the most obvious usecase of the above described workflow. When the user clicks on a recommended episode that is shown in the up-next-dashboard, a slider will show up that allows the user to look at all episodes the corresponding show contains. This slider will open up with an initial position of the episode seen before on the dashboard.
59 |
60 | Because many shows contain hundrets if not thousands of episodes, it will be impractical to request all of them in one go. To reduce API stress and loading time dramatically, only a few episodes need to be requested when opening the slider.
61 |
62 | For better illustration, I will represent the list of episodes a show contains with a line of dashes where every character stands for one episode:
63 |
64 | - `-` means unrequested
65 | - `x` means requested
66 | - `o` means requested and currently shown
67 |
68 | In the following example, the 11th item of a show containing 17 total episodes is opened. The slider will be opened in the second you clicked on it but the content is shown later when the requests finished. To improve user experience, the data of the clicked episode will be requested first.
69 |
70 | When clicked:
71 | ```
72 | ----------o------
73 | ```
74 | After buffer requests finished:
75 | ```
76 | --------xxoxx----
77 | ```
78 |
79 | When moving one episode to the right, the required data is already requested in the background and can be shown immediately. After this, more of the hidden episodes can be requested to keep a buffer of two around the currently visible one.
80 |
81 | When moved one to the right:
82 | ```
83 | --------xxxox----
84 | ```
85 | After buffer requests finished:
86 | ```
87 | --------xxxoxx---
88 | ```
89 |
90 | A similar procedure can be used to show the items inside a watchlist or when clicking on a result in the search-panel.
91 |
--------------------------------------------------------------------------------
/docs/helpers.md:
--------------------------------------------------------------------------------
1 | # Helper Methods
2 |
3 | ## Debugging
4 | For debugging purposes in development mode, the following methods are provided.
5 |
6 | ### Logging messages and errors
7 | The function `debugLog` provides automatic error recognition. To log a normal message you use it like this:
8 |
9 | ```js
10 | debugLog('message title', 'description' [, 'optional message'])
11 | ```
12 |
13 | To log it as an error, for example inside a `.catch(err => ...)` you have to put either `'err'` or `'error'` as the message title. The `'name'` argument lets you give the error a name, so you can find it in your code more easily. The optional argument must be a new `Error` instance. That way you get the location of your debug logging.
14 |
15 | ```js
16 | ...
17 | .catch(err => {
18 | debugLog('error', 'name' [, new Error().stack])
19 | })
20 | ```
21 |
--------------------------------------------------------------------------------
/modules/api/cachers.js:
--------------------------------------------------------------------------------
1 | const {
2 | debugLog
3 | } = require('./../helper.js')
4 |
5 |
6 | module.exports = {
7 | cacheRequest,
8 | cacheSave,
9 | flushCache
10 | }
11 |
12 | const Cache = require('./../cache.js')
13 |
14 |
15 | /**
16 | * Returns the data saved in the cache as the given key and requests it if nothing was saved. The data will be temporarily stored to make processing request arrays easier. To permanently save the results to the cache, use cacheSave().
17 | * @param {String} cacheName Name of the cache data will be saved to
18 | * @param {String} cacheKey Key under which the data is acessed
19 | * @param {Promise} request API request which gets the data in case it wasn't cached before
20 | * @param {Boolean} saveRightAfter Save the data to cache after requesting it
21 | */
22 | function cacheRequest(cacheName, cacheKey, request, saveRightAfter) {
23 | debugLog('cache', `requesting ${cacheKey} from ${cacheName}`)
24 | let cache = new Cache(cacheName)
25 | let cacheContent = cache.getKey(cacheKey)
26 |
27 | if(cacheContent === undefined) {
28 | return request().then(result => {
29 | debugLog('cache', `caching ${cacheKey}`)
30 |
31 | ipcRenderer.send('cache', {
32 | action: 'addKey',
33 | name: cacheName,
34 | key: cacheKey,
35 | data: result
36 | })
37 |
38 | if(saveRightAfter) {
39 | cacheSave(cacheName)
40 | }
41 |
42 | return result
43 | })
44 | } else {
45 | // In this case, everything that was cached is uptodate
46 | debugLog('cache', `restoring ${cacheKey}`)
47 | // Returning a resolved Promise, so it will have the same type as the case above. That way it can be used with a .then() later in the caller without needing to know if the result comes from cache or an API request.
48 | return Promise.resolve(cacheContent)
49 | }
50 | }
51 |
52 | function cacheSave(cacheName) {
53 | ipcRenderer.send('cache', {
54 | action: 'saveKeys',
55 | name: cacheName
56 | })
57 | }
58 |
59 | function flushCache(cacheName, cacheId) {
60 | let cache = new Cache(cacheName)
61 | cache.removeKey(cacheId)
62 | cache.save()
63 | }
64 |
--------------------------------------------------------------------------------
/modules/api/getters.js:
--------------------------------------------------------------------------------
1 | const {
2 | debugLog
3 | } = require('./../helper.js')
4 |
5 |
6 | module.exports = {
7 | getUnfinishedProgressList,
8 | getSeasonList,
9 | getEpisodeData,
10 | getShowImages,
11 | getShowList
12 | }
13 |
14 | const {
15 | requestShowList,
16 | requestShowProgress,
17 | requestSeasonList,
18 | requestEpisodeData,
19 | requestShowImages
20 | } = require('./requesters.js')
21 |
22 | const {
23 | cacheSave,
24 | cacheRequest,
25 | flushCache
26 | } = require('./cachers.js')
27 |
28 |
29 | /**
30 | * Gets an array of n shows and it's progress that are not finished yet. If argument update is true, the updated items are re-requested instead of directly restored from cache.
31 | * @param {Number} n Number of sequential shows with unseen episodes to get
32 | * @param {Boolean} update If cached data should be checked against possible updates
33 | */
34 | function getUnfinishedProgressList(n, update) {
35 | return new Promise(async (resolve, rej) => {
36 | let visible
37 |
38 | // holds ids of shows that require re-requests
39 | let updatedIDs = []
40 |
41 | if(update) {
42 | /**
43 | * This contains a freshly requested show list which could have the last_watched_at property of one or more shows updated and/or one or more additional shows at the start of the list. In the first case, the order of the already stored shows changed. In the second case, all already stored shows are shifted down by some indices. */
44 |
45 | let updated = await getShowList(true)
46 |
47 | updated.forEach(item => {
48 | let oldIndex = visible.map(v => {
49 | return v.show.ids.trakt
50 | }).indexOf(item.show.ids.trakt)
51 |
52 | // store show id if it already exists but had updated watching progress
53 | if(oldIndex > -1) {
54 | if(visible[oldIndex].last_watched_at !== item.last_watched_at) {
55 | updatedIDs.push(item.show.ids.trakt)
56 | }
57 | }
58 | })
59 |
60 | // overwrite old data with new
61 | visible = updated
62 | } else {
63 | visible = await getShowList()
64 | }
65 |
66 | let list = []
67 |
68 | for(let i=0; i {
95 | return requestShowList()
96 | }, true)
97 | }
98 |
99 | function getShowProgress(id, update) {
100 | if(update) {
101 | flushCache('showProgress', id)
102 | }
103 |
104 | return cacheRequest('showProgress', id, () => {
105 | return requestShowProgress(id)
106 | }, false)
107 | }
108 |
109 | function getSeasonList(id, update) {
110 | if(update) {
111 | flushCache('seasonList', id)
112 | }
113 |
114 | return cacheRequest('seasonList', id, () => {
115 | return requestSeasonList(id)
116 | }, true)
117 | }
118 |
119 | function getEpisodeData(id, season, episode, update) {
120 | let cacheId = id+'_'+season+'_'+episode
121 |
122 | if(update) {
123 | flushCache('episodeData', cacheId)
124 | }
125 |
126 | return cacheRequest('episodeData', cacheId, () => {
127 | return requestEpisodeData(id, season, episode)
128 | }, true)
129 | }
130 |
131 | function getShowImages(id, update) {
132 | if(update) {
133 | flushCache('images', id)
134 | }
135 |
136 | return cacheRequest('images', id, () => {
137 | return requestShowImages(id)
138 | }, true)
139 | }
140 |
--------------------------------------------------------------------------------
/modules/api/requesters.js:
--------------------------------------------------------------------------------
1 | const {
2 | debugLog
3 | } = require('./../helper.js')
4 |
5 |
6 | module.exports = {
7 | requestShowList,
8 | requestShowProgress,
9 | requestSeasonList,
10 | requestEpisodeList,
11 | requestEpisodeData,
12 | requestShowImages
13 | }
14 |
15 |
16 | function requestShowList() {
17 | function filterAndSortShows(a, h) {
18 | /**
19 | * Pay attention as the order in which the shows are sorted is by the date of last interaction. This interaction is not always a newly added episode but might also be a comment, rating or anything else unrelated to the watching history. Thus, it is not guaranteed that the order in which they appear in this list is the order of watching. */
20 | let hiddenIds = h.map(item => item.show.ids.trakt)
21 | let visible = a.filter(item => {
22 | return !hiddenIds.includes(item.show.ids.trakt)
23 | })
24 |
25 | visible.sort((a, b) => {
26 | let aTime = new Date(a.last_watched_at).valueOf()
27 | let bTime = new Date(b.last_watched_at).valueOf()
28 |
29 | if(aTime > bTime) return -1
30 | if(aTime < bTime) return 1
31 | return 0
32 | })
33 |
34 | return visible
35 | }
36 |
37 | /** return[]:
38 | * last_updated_at
39 | * last_watched_at
40 | * plays
41 | * reset_at
42 | * seasons[]:
43 | * episodes[]:
44 | * last_watched_at
45 | * number
46 | * plays
47 | * number
48 | * show:
49 | * ids:
50 | * imdb, slig, tmdb, trakt, tvdb, tvrange
51 | * title
52 | * year
53 | */
54 | return new Promise(async (resolve, rej) => {
55 | let all = await requestWatchedShows()
56 | let hidden = await requestHiddenItems()
57 | let visible = filterAndSortShows(all, hidden)
58 |
59 | resolve(visible)
60 | })
61 | }
62 |
63 |
64 | // TRAKT
65 |
66 | function requestShowProgress(id) {
67 | /** return:
68 | * aired
69 | * completed
70 | * hidden_seasons[]:
71 | * number
72 | * ids[]:
73 | * trakt, tvdb, tmdb, imdb
74 | * last_episode[]:
75 | * season
76 | * number
77 | * title
78 | * ids[]:
79 | * trakt, tvdb, tmdb, imdb
80 | * last_watched_at
81 | * next_episode[]:
82 | * season
83 | * number
84 | * title
85 | * ids[]:
86 | * trakt, tvdb, tmdb, imdb
87 | * reset_at
88 | * seasons[]:
89 | * number
90 | * aired
91 | * completed
92 | * episodes[]:
93 | * number
94 | * completed
95 | * last_watched_at
96 | */
97 | debugLog('api request', 'trakt')
98 | let requestTime = Date.now()
99 | return trakt.shows.progress.watched({
100 | id: id,
101 | extended: 'full'
102 | }).then(res => {
103 | debugLog('requesting time', Date.now()-requestTime)
104 | return res
105 | })
106 | }
107 |
108 | function requestWatchedShows() {
109 | debugLog('api request', 'trakt')
110 | let requestTime = Date.now()
111 | return trakt.sync.watched({
112 | type: 'shows'
113 | }).then(res => {
114 | debugLog('requesting time', Date.now()-requestTime)
115 | return res
116 | })
117 | }
118 |
119 | function requestHiddenItems() {
120 | debugLog('api request', 'trakt')
121 | let requestTime = Date.now()
122 | return trakt.users.hidden.get({
123 | section: 'progress_watched',
124 | limit: 100
125 | }).then(res => {
126 | debugLog('requesting time', Date.now()-requestTime)
127 | return res
128 | })
129 | }
130 |
131 | function requestSeasonList(id) {
132 | /**
133 | * []:
134 | * number
135 | * ids: trakt, tvdb, tmdb
136 | * rating
137 | * votes
138 | * episode_count
139 | * aired_episodes
140 | * title
141 | * overview
142 | * first_aired
143 | * network
144 | */
145 | debugLog('api request', 'trakt')
146 | let requestTime = Date.now()
147 | return trakt.seasons.summary({
148 | id: id, // showid
149 | extended: 'full'
150 | }).then(res => {
151 | debugLog('requesting time', Date.now()-requestTime)
152 | return res
153 | })
154 | }
155 |
156 | function requestEpisodeList(id, season) {
157 | debugLog('api request', 'trakt')
158 | let requestTime = Date.now()
159 | return trakt.seasons.season({
160 | id: id,
161 | season: season
162 | }).then(res => {
163 | debugLog('requesting time', Date.now()-requestTime)
164 | return res
165 | })
166 | }
167 |
168 | function requestEpisodeData(id, season, episode) {
169 | debugLog('api request', 'trakt')
170 | let requestTime = Date.now()
171 | return trakt.episodes.summary({
172 | id: id,
173 | season: season,
174 | episode: episode,
175 | extended: 'full'
176 | }).then(res => {
177 | debugLog('requesting time', Date.now()-requestTime)
178 | return res
179 | })
180 | }
181 |
182 |
183 | // FANART
184 |
185 | function requestShowImages(id) {
186 | // id is tvdb-id
187 | debugLog('api request', 'fanart')
188 | let requestTime = Date.now()
189 | return fanart.shows.get(id).then(res => {
190 | debugLog('requesting time', Date.now()-requestTime)
191 | return res
192 | })
193 | }
194 |
--------------------------------------------------------------------------------
/modules/cache.js:
--------------------------------------------------------------------------------
1 | /*
2 | This module implements a caching class which can be used dynamically over all pages.
3 | */
4 |
5 | const flatCache = require('flat-cache')
6 | const path = require('path')
7 |
8 | const {
9 | config, debugLog
10 | } = require('./helper.js')
11 |
12 | module.exports = class Cache {
13 | constructor(name, cacheTime=0) {
14 | this.name = name
15 | this.path = path.join(__dirname, config.client.cache.path)
16 | this.cache = flatCache.load(name, this.path)
17 | this.expire = cacheTime === 0 ? false : cacheTime * 1000 * 60
18 | }
19 | getKey(key) {
20 | let now = new Date().getTime()
21 | let value = this.cache.getKey(key)
22 | if(value === undefined || (value.expire !== false && value.expire < now)) {
23 | return undefined
24 | } else {
25 | return value.data
26 | }
27 | }
28 | setKey(key, value) {
29 | let now = new Date().getTime()
30 | this.cache.setKey(key, {
31 | expire: this.expire === false ? false : now + this.expire,
32 | data: value
33 | })
34 | }
35 | removeKey(key) {
36 | this.cache.removeKey(key)
37 | }
38 | save() {
39 | let timer = Date.now()
40 | debugLog('cache', 'saving...')
41 | this.cache.save(true)
42 | debugLog('cache', `saved in ${Date.now() - timer}ms`)
43 | }
44 | remove() {
45 | flatCache.clearCacheById(this.name, this.path)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/modules/helper.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 |
3 | const config = JSON.parse(fs.readFileSync("./config.json", "utf8"))
4 |
5 | const LogQueue = new(require(__dirname+'/queue.js'))({
6 | frequency: 5,
7 | reverse: true
8 | })
9 |
10 |
11 | class IPCChannels {
12 | constructor() {}
13 | log(details) {
14 | switch(details.action) {
15 | case 'save': {
16 | let logPath = './.log'
17 | LogQueue.add(function() {
18 | fs.stat(logPath, function(err, stat) {
19 | if(err == null) {
20 | let currentLog = fs.readFileSync(logPath)
21 | fs.writeFileSync(logPath, currentLog+'\n'+details.log)
22 | } else if(err.code == 'ENOENT') {
23 | // file does not exist yet
24 | fs.writeFileSync(logPath, 'TRAKTIFY LOG\n'+details.log)
25 | } else {
26 | console.log('Error occured while saving log: ', err.code)
27 | }
28 | })
29 | })
30 |
31 | break
32 | }
33 |
34 | case 'print': {
35 | printLog(String(details.log).split(','), details.date)
36 |
37 | break
38 | }
39 | }
40 | }
41 | }
42 |
43 | class IPCParallel {
44 | send(channel, details) {
45 | ipcChannels[channel](details)
46 | }
47 | }
48 |
49 | const ipcChannels = new IPCChannels()
50 | const ipcParallel = new IPCParallel()
51 |
52 |
53 | function printLog(args, date) {
54 | date = new Date(date)
55 | let time = `${
56 | date.getHours().toString().length === 1
57 | ? '0'+date.getHours() : date.getHours()
58 | }:${
59 | date.getMinutes().toString().length === 1
60 | ? '0'+date.getMinutes() : date.getMinutes()
61 | }:${
62 | date.getSeconds().toString().length === 1
63 | ? '0'+date.getSeconds() : date.getSeconds()
64 | }`
65 |
66 | if(args[0] == 'err' || args[0] == 'error') {
67 | console.log(`\x1b[41m\x1b[37m${time} -> ${args[0]}:\x1b[0m`, args[1])
68 | if(args[2]) {
69 | console.log(` @ .${args[2].toString().split(/\r\n|\n/)[1].split('traktify')[1].split(')')[0]}`)
70 | }
71 | } else {
72 | let bgColor = '\x1b[47m'
73 | let title = args[0].split('')
74 | if(title[0] === '!') {
75 | title[0] = ''
76 | bgColor = '\x1b[43m'
77 | }
78 | title = title.join('')
79 | console.log(`${bgColor}\x1b[30m${time} -> ${title}:\x1b[0m`, args[1])
80 | if(args.length > 2) {
81 | console.log.apply(null, args.splice(2, args.length-2))
82 | }
83 | }
84 | }
85 |
86 |
87 | function debugLog(...args) {
88 | let ipc
89 | if(typeof ipcRenderer === 'undefined') {
90 | ipc = ipcParallel
91 | } else {
92 | ipc = ipcRenderer
93 | }
94 |
95 | let date = new Date()
96 |
97 | // printing to the terminal if in development mode
98 | if(process.env.NODE_ENV !== 'production') {
99 | ipc.send('log', {
100 | action: 'print',
101 | log: args,
102 | date: date
103 | })
104 | }
105 |
106 | // log is always saved to disk
107 | ipc.send('log', {
108 | action: 'save',
109 | log: date.toISOString()
110 | .split('T').join(' ')
111 | .split('Z').join('')
112 | +': '+args
113 | })
114 | }
115 |
116 | // Range must be an array of two numeric values
117 | function inRange(value, range) {
118 | let [min, max] = range; max < min ? [min, max] = [max, min] : [min, max]
119 | return value >= min && value <= max
120 | }
121 |
122 | // takes a hex color code and changes it's brightness by the given percentage. Positive value to brighten, negative to darken a color. Percentages are taken in range from 0 to 100 (not 0 to 1!).
123 | // function mainly used to generate dark version of the accent colors
124 | function shadeHexColor(hex, percent) {
125 | // convert hex to decimal
126 | let R = parseInt(hex.substring(1,3), 16)
127 | let G = parseInt(hex.substring(3,5), 16)
128 | let B = parseInt(hex.substring(5,7), 16)
129 |
130 | // change by given percentage
131 | B = parseInt(B*(100 + percent)/100)
132 | R = parseInt(R*(100 + percent)/100)
133 | G = parseInt(G*(100 + percent)/100)
134 |
135 | // clip colors to max value
136 | R = R<255 ? R : 255
137 | G = G<255 ? G : 255
138 | B = B<255 ? B : 255
139 |
140 | // zero-ize single-digit values
141 | let RR = R.toString(16).length==1 ? '0'+R.toString(16) : R.toString(16)
142 | let GG = G.toString(16).length==1 ? '0'+G.toString(16) : G.toString(16)
143 | let BB = B.toString(16).length==1 ? '0'+B.toString(16) : B.toString(16)
144 |
145 | return '#'+RR+GG+BB
146 | }
147 |
148 | // Simple helper to clone objects which prevents cross-linking.
149 | function clone(object) {
150 | if(null == object || "object" != typeof object) return object
151 | // create new blank object of same type
152 | let copy = object.constructor()
153 |
154 | // copy all attributes into it
155 | for(let attr in object) {
156 | if(object.hasOwnProperty(attr)) {
157 | copy[attr] = object[attr]
158 | }
159 | }
160 | return copy
161 | }
162 |
163 |
164 | module.exports = {
165 | config, printLog, debugLog, inRange, shadeHexColor, clone, ipcChannels, ipcParallel
166 | }
167 |
--------------------------------------------------------------------------------
/modules/presence.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 | const discordRPC = require('discord-rpc')
3 | const EventEmitter = require('events')
4 | const debugLog = remote.getGlobal('debugLog')
5 |
6 |
7 | module.exports = function(clientId) {
8 | const rpc = new discordRPC.Client({ transport: 'ipc' })
9 |
10 | let connected = false
11 | let activityCache = null
12 |
13 | const instance = new class RP extends EventEmitter {
14 | updatePresence(d) {
15 | if(connected) {
16 | rpc.setActivity(d).catch(err => debugLog('error', '', new Error().stack))
17 | } else {
18 | activityCache = d
19 | }
20 | }
21 |
22 | disconnect() {
23 | rpc.destroy().catch(err => debugLog('error', '', new Error().stack))
24 | }
25 | }()
26 |
27 | rpc.on('error', err => debugLog('error', '', new Error().stack))
28 |
29 | rpc.login({ clientId }).then(() => {
30 | debugLog('rpc', 'connected')
31 | connected = true
32 |
33 | if(activityCache) {
34 | rpc.setActivity(activityCache)
35 | .catch(err => debugLog('error', '', new Error().stack))
36 | activityCache = null
37 | }
38 | }).catch(err => debugLog('error', 'rpc login', new Error().stack))
39 |
40 | return instance
41 | }
42 |
--------------------------------------------------------------------------------
/modules/queue.js:
--------------------------------------------------------------------------------
1 | module.exports = class Queue {
2 | constructor(options) {
3 | // options: { ?frequency, ?reverse }
4 | options = options || {}
5 |
6 | if(options.frequency) {
7 | this._timeOut = 1e3/options.frequency
8 | } else {
9 | this._timeOut = 1e3
10 | }
11 |
12 | this._reverse = options.reverse
13 |
14 | this._taskList = []
15 | }
16 |
17 | add(callback, options) {
18 | // options: { ?args, ?overwrite }
19 | options = options || {}
20 | let job = new Task(options.args, callback)
21 |
22 | if(options.overwrite) {
23 | let duplicates = this._duplicates(job)
24 | duplicates.forEach(i => {
25 | this._taskList.splice(i, 1)
26 | })
27 | }
28 |
29 | // enqueuing job
30 | this._enqueue(job)
31 |
32 | // start the queue if it hasn't already
33 | if(!this._interval) {
34 | // starting ticker
35 | this._interval = setInterval(() => {
36 | let result = this._doTick()
37 | if(!result) {
38 | // stopping ticker
39 | clearInterval(this._interval)
40 | this._interval = null
41 | }
42 | }, this._timeOut)
43 | }
44 | }
45 |
46 | _enqueue(job) {
47 | let len = this._taskList.push(job)
48 | }
49 |
50 | _doTick() {
51 | let jobIndex
52 | if(this._reverse) {
53 | jobIndex = 0
54 | } else {
55 | jobIndex = this._taskList.length - 1
56 | }
57 |
58 |
59 | if(this._taskList.length === 0) {
60 | // no job available
61 | return null
62 | } else {
63 | // ticking
64 | let job = this._taskList[jobIndex]
65 | let result = job.run()
66 | .then(r => r)
67 | .catch(e => e)
68 |
69 | if(this._reverse) {
70 | this._taskList.shift()
71 | } else {
72 | this._taskList.pop()
73 | }
74 |
75 | return result
76 | }
77 | }
78 |
79 | _duplicates(j) {
80 | let indices = []
81 | this._taskList.forEach((t, index) => {
82 | if(
83 | String(j.args) == String(t.args)
84 | && String(j.callback) == String(t.callback)
85 | ) {
86 | // found duplicate
87 | indices.push(index)
88 | }
89 | })
90 | return indices
91 | }
92 | }
93 |
94 |
95 | class Task {
96 | constructor(args, callback) {
97 | this.time = Date.now()
98 | this.callback = callback
99 | this.args = args
100 | }
101 |
102 | run() {
103 | return new Promise(async (resolve, rej) => {
104 | let result
105 | try {
106 | result = await this.callback(this.args)
107 | } catch(err) {
108 | if(err) {
109 | rej(err)
110 | }
111 | }
112 | resolve(result)
113 | })
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/modules/request.js:
--------------------------------------------------------------------------------
1 | let fanart = remote.getGlobal('fanart')
2 | let config = remote.getGlobal('config')
3 |
4 | module.exports = {
5 | newActivitiesAvailable,
6 | searchRequestHelper,
7 | getUserStats,
8 | getSeasonPoster,
9 | reloadAllItems
10 | }
11 |
12 | const {
13 | debugLog
14 | } = require('./helper.js')
15 |
16 |
17 | //:::: SYNCING ::::\\
18 | let syncingCache = new Cache('syncing')
19 |
20 | // returns an array of activity keys that have unseen activity
21 | async function newActivitiesAvailable() {
22 | syncingCache.remove()
23 | syncingCache.save()
24 |
25 | let latest = await getLatestActivities()
26 | debugLog('request finished', latest.all)
27 | let cacheContent = syncingCache.getKey('latestActivities')
28 |
29 | if(cacheContent !== undefined) {
30 | debugLog('cache content', cacheContent.all)
31 | if(latest.all === cacheContent.all) {
32 | debugLog('latest activities', 'nothing new')
33 | return []
34 | } else {
35 | let updates = []
36 | for(let scope in cacheContent) {
37 | if(scope !== 'all') {
38 | for(let action in cacheContent[scope]) {
39 | let dateOld = cacheContent[scope][action]
40 | let dateNew = latest[scope][action]
41 | if(dateNew !== dateOld) {
42 | updates.push(scope)
43 | debugLog('latest activities', action+' @ '+scope)
44 | }
45 | }
46 | }
47 | }
48 | syncingCache.setKey('latestActivities', latest)
49 | syncingCache.save()
50 |
51 | return updates
52 | }
53 | } else {
54 | debugLog('latest activities', 'all are unseen')
55 | // save latest activities if its the first time caching it
56 | syncingCache.setKey('latestActivities', latest)
57 | syncingCache.save()
58 |
59 | return [ 'movies', 'episodes', 'shows', 'seasons', 'comments', 'lists' ]
60 | }
61 | }
62 |
63 | function getLatestActivities() {
64 | debugLog('api request', 'latest trakt activites')
65 | return trakt.sync.last_activities().then(res => res)
66 | }
67 |
68 | // This function is triggered when hitting the reload button on the dashboard.
69 | async function reloadAllItems(_this, _par, onStart, onFinish) {
70 | onStart(_this)
71 | await generatePosterSection(true)
72 | onFinish(_this, _par.children[0])
73 | }
74 |
75 |
76 | //:::: IMAGE REQUESTING ::::\\
77 | let imageCache = new Cache('images')
78 |
79 | async function getSeasonPoster(showId, season) {
80 | let cacheKey = showId+'_'+season
81 | let cacheContent = imageCache.getKey(cacheKey)
82 |
83 | if(cacheContent === undefined) {
84 | let data = await requestSeasonPoster(showId, season)
85 | debugLog('caching', cacheKey)
86 |
87 | ipcRenderer.send('cache', {
88 | action: 'addKey',
89 | name: 'images',
90 | key: cacheKey,
91 | data: data
92 | })
93 |
94 | ipcRenderer.send('cache', {
95 | action: 'saveKeys',
96 | name: 'images'
97 | })
98 |
99 | return data
100 | } else {
101 | debugLog('cache available', cacheKey)
102 | return cacheContent
103 | }
104 | }
105 |
106 | function requestSeasonPoster(showId, season) {
107 | return fanart.shows.get(showId).then(async result => {
108 | function currentSeasonPoster() {
109 | let index = -1
110 | let first = true
111 | let preindex = -1
112 | for(let i in result.seasonposter) {
113 | let poster = result.seasonposter[i]
114 | if(poster.season == season) {
115 | if(poster.lang == 'en') {
116 | debugLog('poster', 'found fitting')
117 | return i
118 | }
119 | if(first) {
120 | preindex = i
121 | first = false
122 | }
123 | }
124 | }
125 | if(preindex > -1) {
126 | debugLog('poster', 'only found different language')
127 | return preindex
128 | } else {
129 | debugLog('poster', 'did not found correct season')
130 | return index
131 | }
132 | }
133 |
134 | let url = ''
135 |
136 | function fallback() {
137 | if(Object.keys(result).includes('tvposter')) {
138 | debugLog('poster', 'placing tv poster as fallback')
139 | url = result.tvposter[0].url
140 | } else {
141 | debugLog('poster', 'replacing unavailable poster')
142 | url = '../../assets/'+config.client.placeholder.poster
143 | }
144 | }
145 |
146 | if(Object.keys(result).includes('seasonposter')) {
147 | let index = await currentSeasonPoster()
148 | if(index > -1) {
149 | url = result.seasonposter[index].url
150 | } else {
151 | fallback()
152 | }
153 | } else {
154 | fallback()
155 | }
156 |
157 | return url
158 | }).catch(() => {
159 | debugLog('error', 'fanart not found', new Error().stack)
160 | return '../../assets/'+config.client.placeholder.poster
161 | })
162 | }
163 |
164 | //:::: SEARCH ::::\\
165 | let searchQueryCache = new Cache('searchQuery')
166 |
167 | function searchRequestHelper(text) {
168 | let cacheContent = searchQueryCache.getKey(text)
169 | // check if text was searched already, send earlier results if so
170 | if(cacheContent !== undefined) {
171 | debugLog('cache content', cacheContent)
172 | return Promise.resolve(cacheContent)
173 | }
174 |
175 | let query = formatSearch(text)
176 | if(!query) return null
177 |
178 | return trakt.search.text({
179 | type: query.type,
180 | query: query.filtered,
181 | extended: 'full'
182 | })
183 | // got search results from trakt
184 | .then(searchResults => {
185 | debugLog('api request', 'trakt')
186 | debugLog('search results', searchResults.map(r => r[r.type].ids.trakt))
187 |
188 | return new Promise((resolve, reject) => {
189 | let fanartQueue = []
190 |
191 | searchResults.forEach(result => {
192 | let mv = result.type == 'movie' ? 'm' : 'v'
193 |
194 | fanartQueue.push(fanart[result.type + 's']
195 | .get(result[result.type].ids['t' + mv + 'db'])
196 | // got data from fanart
197 | .then(fanResult => {
198 | debugLog('api request', 'fanart')
199 | return fanResult
200 | }).catch(err => debugLog('error', 'fanart', new Error().stack))
201 | )
202 | })
203 |
204 | resolve([searchResults, fanartQueue])
205 | })
206 | // search queue filled with promises
207 | .then(([trakt, fanart]) => {
208 | debugLog('queue', 'starting')
209 |
210 | return Promise.all(fanart.map(p => p.catch(e => e)))
211 | .then(resolvedQueue => {
212 | let requestArray = []
213 |
214 | resolvedQueue.forEach((item, index) => {
215 | requestArray.push({
216 | trakt: trakt[index],
217 | fanart: item
218 | })
219 | })
220 |
221 | let data = {
222 | date: Date.now(),
223 | result: requestArray
224 | }
225 |
226 | debugLog('caching', text)
227 |
228 | ipcRenderer.send('cache', {
229 | action: 'addKey',
230 | name: 'searchQuery',
231 | key: text,
232 | data: data
233 | })
234 |
235 | ipcRenderer.send('cache', {
236 | action: 'save',
237 | name: 'searchQuery'
238 | })
239 |
240 | return data
241 | })
242 | .catch(err => {
243 | debugLog('error', 'promise', new Error().stack)
244 | })
245 | })
246 | }).catch(err => {
247 | debugLog('error', 'trakt', new Error().stack)
248 | })
249 | }
250 |
251 | // To sent API requests, we need a properly formed query. This formats the raw input text into a usable object.
252 | function formatSearch(text) {
253 | let searchOptions = [
254 | 's', 'show', 'shows', 'tv',
255 | 'm', 'movie',
256 | 'e', 'ep', 'episode',
257 | 'p', 'person'
258 | ].map(o => o + ':')
259 |
260 | let query = startsWithFilter(text, searchOptions, ':')
261 |
262 | // This converts the simplified search type into a request-friendly one
263 | switch(query.found) {
264 | case 's':
265 | case 'show':
266 | case 'shows':
267 | case 'tv': {
268 | query.type = 'show'
269 | break
270 | }
271 | case 'm':
272 | case 'movie': {
273 | query.type = 'movie'
274 | break
275 | }
276 | case 'e':
277 | case 'ep':
278 | case 'episode': {
279 | query.type = 'episode'
280 | break
281 | }
282 | case 'p':
283 | case 'person': {
284 | query.type = 'person'
285 | break
286 | }
287 | default: {
288 | break
289 | }
290 | }
291 |
292 | debugLog('query', query)
293 | return query
294 | }
295 |
296 | function startsWithFilter(string, options, removeFromFilter) {
297 | string = string.toString()
298 | for(let opt in options) {
299 | if(string.startsWith(options[opt])) {
300 | return {
301 | found: options[opt].split(removeFromFilter || '').join(''),
302 | filtered: string.split(options[opt])[1]
303 | }
304 | }
305 | }
306 |
307 | return {
308 | found: null,
309 | filtered: string
310 | }
311 | }
312 |
313 |
314 | //:::: STATS ::::\\
315 |
316 | // we're using the syncingCache from above here
317 | async function getUserStats() {
318 | let cacheContent = syncingCache.getKey('userStats')
319 | if(cacheContent === undefined) {
320 | return requestUserStats().then(userStats => {
321 | debugLog('caching', 'user stats')
322 |
323 | ipcRenderer.send('cache', {
324 | action: 'addKey',
325 | name: 'syncing',
326 | key: 'userStats',
327 | data: userStats
328 | })
329 |
330 | ipcRenderer.send('cache', {
331 | action: 'save',
332 | name: 'syncing'
333 | })
334 |
335 | return userStats
336 | })
337 | } else {
338 | // In this case, everything that was cached is uptodate
339 | debugLog('cache available', 'user stats')
340 | return cacheContent
341 | }
342 | }
343 |
344 | function requestUserStats() {
345 | debugLog('api request', 'user stats')
346 | let requestTime = Date.now()
347 | return new Promise(async (resolve, reject) => {
348 | let userSettings = await getUserSettings()
349 | resolve(
350 | trakt.users.stats({
351 | username: userSettings.user.username
352 | }).then(res => {
353 | debugLog('request finished', Date.now()-requestTime)
354 | return res
355 | })
356 | )
357 | })
358 | }
359 |
360 | async function getUserSettings() {
361 | let userSettings = await requestUserSettings()
362 | return userSettings
363 | }
364 |
365 | function requestUserSettings() {
366 | debugLog('api request', 'user information')
367 | let requestTime = Date.now()
368 | return trakt.users.settings().then(res => {
369 | debugLog('request finished', Date.now()-requestTime)
370 | return res
371 | })
372 | }
373 |
374 | //
375 | // BUFFER
376 | //
377 |
378 | const {
379 | getShowList,
380 | getEpisodeData,
381 | getSeasonList,
382 | getShowImages
383 | } = require('./api/getters.js')
384 |
385 | async function convertTraktToTvdb(id) {
386 | return await getShowList().then(list => {
387 | let trakt = list.map(s => s.show.ids.trakt)
388 | let i = trakt.indexOf(Number(id))
389 | return list[i].show.ids.tvdb
390 | })
391 | }
392 |
393 | /**
394 | * This buffer instance handles the traffic between API, Cache and renderer.
395 | * It is mainly used for the card slider which openes when clicked on a episode poster.
396 | * Inside this slider the user is able to move back and forth through every episode of the tv show. Loading the data for all episodes at once would take first of all way too much time and secondly it would stress the APIs too much which would lead to rate limiting.
397 | * The buffer is meant to reduce these load times and stresses to make the user experice fluid and snappy.
398 | *
399 | * A buffer instance is unique to one show. To buffer another show, the instance would have to be either overwritten or a new one must be created.
400 | */
401 | module.exports.showBuffer = class showBuffer {
402 | constructor(showId) {
403 | debugLog('buffer', 'creating new instance for '+showId)
404 | this.id = showId
405 | this.tvdb = convertTraktToTvdb(this.id)
406 |
407 | this.show = {
408 | seasons: []
409 | }
410 | // array of episode_count
411 | this.tree = []
412 | // plain 1D array with a list of all items
413 | this.items = []
414 | // current position
415 | this.current = 0
416 |
417 | // indices that are in queue to be requested
418 | this.queue = []
419 |
420 | this.timer = 0
421 | }
422 |
423 | /**
424 | * Initialize an array with an element for each episode.
425 | * @param {Number} size Total size of TV show
426 | */
427 | applySize(size) {
428 | for(let i=0; i {
442 | if(i < s) {
443 | // add all episodes on seasons below current
444 | abs += c
445 | }
446 | })
447 |
448 | // add pos inside current season
449 | abs += e
450 | return abs
451 | }
452 |
453 | /**
454 | * Initialize the buffer at a given point in the TV show.
455 | * @param {Number} s Number of the season
456 | * @param {Number} e Number of the episode
457 | * @param {Function} on.size Callback when the size of the entire show is known
458 | * @param {Function} on.first Callback when full data for the current episode is available
459 | * @param {Function} on.buffer Callback when one episode from the buffer area got requested. Will trigger once for each element.
460 | */
461 | async initAt(s, e, on) {
462 | s = Number(s) // prevent string addition
463 | e = Number(e)
464 | debugLog('!buffer', 'initializing new instance')
465 | this.timer = Date.now()
466 |
467 | await getEpisodeData(this.id, s, e).then(async d => {
468 | // We first have to know the length of the season. With that information, we can add the required amount of cards to the stack—which will be done by the renderer receiving the callback.
469 | await getSeasonList(this.id).then(seasons => {
470 | this.show.seasons = seasons
471 |
472 | let total = 0
473 | seasons.forEach(el => {
474 | if(el.title != 'Specials') {
475 | // counting episodes but ignoring specials
476 | total += el.episode_count
477 | this.tree.push(el.episode_count)
478 | }
479 | })
480 |
481 | let size = {
482 | total: total,
483 | current: this.posToAbs(s, e)
484 | }
485 |
486 | this.current = size.current
487 |
488 | on.size(size)
489 | this.applySize(total)
490 | })
491 |
492 | // Now, we can send the episode data back via the callback and save it to the local scope.
493 | await on.first(this.formatUpdates(d))
494 | this.items[this.current-1] = { data: d, images: null }
495 |
496 | this.updateBuffer(this.current, on)
497 | })
498 |
499 | on.images(await this.requestImages(this.current), this.current-1)
500 | }
501 |
502 | /**
503 | * Restore the buffer at a given point in the TV show. Restoration is faster than initialization.
504 | * @param {Number} s Number of the season
505 | * @param {Number} e Number of the episode
506 | * @param {Function} on.size Callback when the size of the entire show is known
507 | * @param {Function} on.first Callback when full data for the current episode is available
508 | * @param {Function} on.buffer Callback when one episode from the buffer area got requested. Will trigger once for each element.
509 | */
510 | async openAt(s, e, on) {
511 | s = Number(s)
512 | e = Number(e)
513 | debugLog('!buffer', 'opening existing instance')
514 | this.timer = Date.now()
515 |
516 | this.current = this.posToAbs(s, e)
517 |
518 | on.size({
519 | total: this.tree.reduce((p, c) => p + c),
520 | current: this.current
521 | })
522 |
523 | let firstData = await this.requestEpisode(this.current)
524 | let firstRes = this.formatUpdates(firstData)
525 | on.first(firstRes)
526 |
527 | this.updateBuffer(this.current, on)
528 | }
529 |
530 | /**
531 | * Moving through the buffer by a certain amount. To prevent buffer piling, this function has to be called in delay with the total movement happened during that delay.
532 | * @param {Number} dir The amount of episodes to move, negative to move backwards.
533 | * @param {Function} on.first Callback for the data of the seen item.
534 | * @param {Function} on.buffer Callback for the buffered items.
535 | */
536 | move(dir, on) {
537 | debugLog('!buffer', `moving ${dir>0 ? 'right' : 'left'}`)
538 | this.timer = Date.now()
539 | let newPos = this.current + dir
540 |
541 | // The new buffer position would be out of range. I hope this will never happen but in case it does, we'll clip it to the max or min.
542 | if(newPos < 1) {
543 | newPos = 1
544 | } else if(newPos > this.items.length) {
545 | newPos = this.items.length
546 | }
547 |
548 | // update the current position in the buffer
549 | this.current = newPos
550 | this.updateBuffer(newPos, on)
551 | }
552 |
553 | /**
554 | * Updates the buffer to the new position and calculates the surrounding area to request.
555 | * @param {Number} pos New position to move to, absolute number.
556 | * @param {Function} on.first Callback for the data of the seen item.
557 | * @param {Function} on.buffer Callback for the buffered items.
558 | */
559 | updateBuffer(pos, on) {
560 | let range = [0, 1, -1, 2, -2]
561 | range.forEach(r => {
562 | let epPos = pos+r
563 | // the absolute positions can't become smaller than 1 or greater the show length
564 | if(epPos > 0 && epPos <= this.items.length) {
565 | this.queue.push(epPos)
566 | }
567 | })
568 |
569 | this.nextInQueue(on)
570 | }
571 |
572 | /**
573 | * Recursive function that runs over the queue list where items were added by the updateBuffer() function.
574 | * @param {Function} on.first Callback for the data of the seen item.
575 | * @param {Function} on.buffer Callback for the buffered items.
576 | */
577 | async nextInQueue(on) {
578 | if(this.queue.length > 0) {
579 | let reqPos = this.queue[0]
580 | // remove it and possible dublicates from the queue
581 | this.queue = this.queue.filter(q => q != reqPos)
582 |
583 | let epData = this.formatUpdates(await this.requestEpisode(reqPos))
584 |
585 | if(reqPos == this.current) {
586 | on.first(epData)
587 | } else {
588 | on.buffer(epData, reqPos-1)
589 | }
590 |
591 | on.images(await this.requestImages(reqPos), reqPos-1)
592 |
593 | // some time delay to allow flushing the quere
594 | setTimeout(() => {
595 | this.nextInQueue(on)
596 | }, 200)
597 | } else {
598 | // stop timer
599 | debugLog('!buffer', Date.now()-this.timer + 'ms')
600 | }
601 | }
602 |
603 | flushQueue() {
604 | // This empties the queue, it does not fully kill the requesting process if some is currently running! The flushing is possible since the requesting queue is delayed after each finished item.
605 | this.queue = []
606 | }
607 |
608 | /**
609 | * Sends back data for a given episode number. It will automatically check for available buffer and cache data. Only if nothing is saved already, the API will be used to get the data.
610 | * @param {Number} pos Absolute position of the episode in the show.
611 | */
612 | requestEpisode(pos) {
613 | if(this.items[pos-1] == null) {
614 | // a simple helper
615 | let counter = 0
616 |
617 | // these two will be determined in the following loop
618 | let seasonIndex = 0
619 | let episodeIndex = 0
620 | for(let i=0; i {
632 | this.items[pos-1] = { data: epData }
633 | return epData
634 | })
635 | } else {
636 | // item was already buffered before
637 | debugLog('!buffer', `restoring item ${pos-1}`)
638 | return Promise.resolve(this.items[pos-1].data)
639 | }
640 | }
641 |
642 | /**
643 | * Send back object of image URLs for a given item in the buffer. Through a getter function the API and Cache load will be balanced automatically. Result is in form of a promise.
644 | * @param {Number} pos Absolute position of the episode in the show.
645 | */
646 | async requestImages(pos) {
647 | if(this.items[pos-1].images == null) {
648 | // no images buffered
649 | return getShowImages(await this.tvdb).then(r => {
650 | this.items[pos-1].images = {
651 | banner: r.tvbanner[0].url,
652 | poster: r.tvposter[0].url
653 | }
654 | return this.items[pos-1].images
655 | })
656 | } else {
657 | // images were buffered before
658 | return Promise.resolve(this.items[pos-1].images)
659 | }
660 | }
661 |
662 | formatUpdates(raw) {
663 | return {
664 | ratingPercent: ''+Math.round(raw.rating * 10),
665 | episodeTitle: raw.title,
666 | episodeNumber: (() => {
667 | let num = ''+raw.number
668 | if(num.length < 2) {
669 | num = '0'+num
670 | }
671 | return num
672 | })(),
673 | seasonNumber: ''+raw.season,
674 | absoluteNumber: ''+raw.number_abs,
675 | description: raw.overview
676 | }
677 | }
678 | }
679 |
--------------------------------------------------------------------------------
/modules/rpc.js:
--------------------------------------------------------------------------------
1 | let config = remote.getGlobal('config')
2 | let on = config.client.settings.app['discord rpc'].status
3 |
4 | let client = require('./presence.js')
5 |
6 | if(on) client = client(process.env.discord_key)
7 |
8 | module.exports = {
9 | update: update
10 | }
11 |
12 | async function update(options) {
13 | options = await options
14 | debugLog('rpc status', options.state)
15 |
16 | let total = ((options.time.movies+options.time.shows)/60).toFixed(1)
17 |
18 | client.updatePresence({
19 | details: `watched for ${total} hours`,
20 | state: options.state,
21 | largeImageKey: 'trakt',
22 | largeImageText: 'traktify',
23 | instance: false
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "traktify",
3 | "version": "0.1.8",
4 | "description": "multi platform desktop app for trakt.tv",
5 | "main": "app.js",
6 | "scripts": {
7 | "start": "electron .",
8 | "clearCache": "rm -f -r ./.cache/"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/CodingBobby/traktify.git"
13 | },
14 | "keywords": [
15 | "trakt",
16 | "desktop",
17 | "app"
18 | ],
19 | "author": "CodingBobby, Bumbleboss",
20 | "license": "ISC",
21 | "bugs": {
22 | "url": "https://github.com/CodingBobby/traktify/issues"
23 | },
24 | "homepage": "https://codingbobby.xyz/traktify",
25 | "devDependencies": {
26 | "electron": "^4.2.8"
27 | },
28 | "dependencies": {
29 | "background": "^0.3.3",
30 | "discord-rpc": "^3.0.2",
31 | "electron-window-state": "^5.0.3",
32 | "fanart.tv": "^2.1.0",
33 | "flat-cache": "^2.0.1",
34 | "fs": "0.0.1-security",
35 | "moviedb-promise": "^1.4.1",
36 | "node-tvdb": "^4.1.0",
37 | "path": "^0.12.7",
38 | "register-scheme": "0.0.2",
39 | "request": "^2.88.0",
40 | "rimraf": "^3.0.0",
41 | "trakt.tv": "^7.2.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/pages/dashboard/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
27 | Traktify | Dashboard
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | -
36 |
39 |
40 | -
41 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
65 |
66 |
67 |
68 |
72 |
73 |
74 | History
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/pages/dashboard/index.js:
--------------------------------------------------------------------------------
1 | const trakt = remote.getGlobal('trakt')
2 | const fanart = remote.getGlobal('fanart')
3 | const getSettings = remote.getGlobal('getSettings')
4 | const setSetting = remote.getGlobal('setSetting')
5 | const defaultAll = remote.getGlobal('defaultAll')
6 | const updateApp = remote.getGlobal('updateApp')
7 | const relaunchApp = remote.getGlobal('relaunchApp')
8 |
9 | let config = remote.getGlobal('config')
10 |
11 | // Here we update the app with saved settings after the window is created
12 | window.onload = function() {
13 | debugLog('window', 'dashboard loading')
14 | updateApp() // update settings
15 | generatePosterSection() // show the up next to watch posters
16 | updateRpc() // show rpc on discord, handling the on/off setting is done within this function and doesn't have to be done here!
17 | }
18 |
19 | // This guy waits for messages on the 'modify-root' channel. The messages contain setting objects that then get applied to the 'master.css' style sheet. It is used to change the look of the app.
20 | ipcRenderer.on('modify-root', (event, data) => {
21 | let variables = document.styleSheets[0]
22 | .cssRules[0].style.cssText.split(';')
23 |
24 | let result = {}
25 | for(let i in variables) {
26 | let a = variables[i].split(':')
27 | if(a[0] !== '') {
28 | result[a[0].trim()] = a[1].trim()
29 | }
30 | }
31 |
32 | let keys = Object.keys(result)
33 | document.documentElement.style.setProperty(keys[keys.indexOf(data.name)], data.value)
34 | })
35 |
36 | // Identifies the currently open panel. Makes it easier to check against closed panels, as this would require each of them to check on their own.
37 | let openedPanel = null
38 | /**
39 | * sidebar,
40 | * cards
41 | */
42 |
43 | // Here, dashboard-wide shortcuts are defined. The 'meta' key represents CMD on macOS and Ctrl on Windows
44 | document.onkeydown = function() {
45 | if(event.metaKey && event.keyCode === 83) { // meta + S
46 | debugLog('shortcut', 'Meta + S')
47 | if(openedPanel !== 'cards') {
48 | show(document.getElementById('search_button_side'))
49 | triggerSidePanel('search')
50 | }
51 | return false
52 | } else if(event.metaKey && event.keyCode === 188) { // meta + ,
53 | debugLog('shortcut', 'Meta + ,')
54 | if(openedPanel !== 'cards') {
55 | show(document.getElementById('settings_button_side'))
56 | triggerSidePanel('settings')
57 | }
58 | return false
59 | } else if(event.keyCode === 27) { // ESC
60 | debugLog('shortcut', 'ESC')
61 | // close the currently open panel
62 | if(openedPanel == 'sidebar') {
63 | triggerSidePanel(sideBar.status)
64 | } else if(openedPanel == 'cards') {
65 | triggerInfoCardOverlay()
66 | }
67 | } else if(event.keyCode === 39) { // arrow right
68 | debugLog('shortcut', 'ArrowRight')
69 | if(openedPanel == 'cards') {
70 | moveCards(null, 'right')
71 | }
72 | } else if(event.keyCode === 37) { // arrow left
73 | debugLog('shortcut', 'ArrowLeft')
74 | if(openedPanel == 'cards') {
75 | moveCards(null, 'left')
76 | }
77 | } else if(event.keyCode === 38) { // arrow up
78 |
79 | } else if(event.keyCode === 40) { // arrow down
80 |
81 | }
82 | }
83 |
84 |
85 | // This highlights the passed element. It does this by giving the element the 'selected' class and removing it from all siblings
86 | function show(x) {
87 | let par = x.parentElement.parentElement;
88 | [...par.children].forEach(element => {
89 | if(element.children[0] === x) {
90 | x.classList.add('selected')
91 | } else {
92 | element.children[0].classList.remove('selected')
93 | }
94 | })
95 | }
96 |
97 | function rotate(x) {
98 | if(x.classList.contains('rotating')) {
99 | x.classList.remove('rotating')
100 | } else {
101 | x.classList.add('rotating')
102 | }
103 | }
104 |
105 | //:::: INFOCARD ::::\\
106 |
107 | // This variable can be overwritten by different new <>Buffer() classes.
108 | let localBuffer
109 |
110 | // moves in one direction through the stacks
111 | function moveCards(clickedButton, direction) {
112 | let stacks = getCardStacks()
113 | switch(direction) {
114 | case 'right':
115 | if(stacks.right.length !== 0) {
116 | let midCard = stacks.middle[0]
117 | midCard.classList.remove('middle_stack')
118 | midCard.classList.add('left_stack')
119 | // get the bottom right one
120 | let rigCard = stacks.right[0]
121 | rigCard.classList.remove('right_stack')
122 | rigCard.classList.add('middle_stack')
123 | }
124 |
125 | // TODO: Logging is only temporary, use results for actual rendering.
126 | localBuffer.move(1, {
127 | first: epData => { // onFirst
128 | // find index of the middle card
129 | let index = getCardStacks().left.length
130 | updateInfoCard(epData, index)
131 | updateLeftRightButtons()
132 | },
133 | buffer: (bufferData, pos) => { // onBuffer
134 | updateInfoCard(bufferData, pos)
135 | },
136 | images: (urls, pos) => { // onImage
137 | updateInfoCardImage(urls, pos)
138 | }
139 | })
140 | break
141 | case 'left':
142 | if(stacks.left.length !== 0) {
143 | // move the middle one
144 | let midCard = stacks.middle[0]
145 | midCard.classList.remove('middle_stack')
146 | midCard.classList.add('right_stack')
147 | // get the top left one
148 | let lefCard = stacks.left[stacks.left.length-1]
149 | lefCard.classList.remove('left_stack')
150 | lefCard.classList.add('middle_stack')
151 | }
152 |
153 | localBuffer.move(-1, {
154 | first: epData => { // onFirst
155 | // find index of the middle card
156 | let index = getCardStacks().left.length
157 | updateInfoCard(epData, index)
158 | updateLeftRightButtons()
159 | },
160 | buffer: (bufferData, pos) => { // onBuffer
161 | updateInfoCard(bufferData, pos)
162 | },
163 | images: (urls, pos) => { // onImage
164 | updateInfoCardImage(urls, pos)
165 | }
166 | })
167 | break
168 | }
169 | updateLeftRightButtons()
170 | }
171 |
172 | function getCardStacks() {
173 | return {
174 | left: document.getElementsByClassName('left_stack'),
175 | middle: document.getElementsByClassName('middle_stack'),
176 | right: document.getElementsByClassName('right_stack')
177 | }
178 | }
179 |
180 | function updateLeftRightButtons() {
181 | let stacks = getCardStacks()
182 | let leftButton = document.getElementById('stack_left_button')
183 | let rightButton = document.getElementById('stack_right_button')
184 |
185 | // check the left stack
186 | if(stacks.left.length === 0) {
187 | leftButton.style.display = 'none'
188 | } else {
189 | leftButton.style.display = 'flex'
190 | }
191 |
192 | // and now the right one
193 | if(stacks.right.length === 0) {
194 | rightButton.style.display = 'none'
195 | } else {
196 | rightButton.style.display = 'flex'
197 | }
198 |
199 | // update the position of the slider thumb
200 | generateStackSlider()
201 | }
202 |
203 | // Closes the info card if already open. Currently, it is only opened by html onclick events. Opening it with this function could be done in future, possibly to speed up loading.
204 | function triggerInfoCardOverlay() {
205 | let infocard_overlay = document.getElementById('infocard_overlay')
206 | let dark_overlay = document.getElementById('info_overlay')
207 | if(infocard_overlay.style.display === 'none') {
208 | // open it
209 | openedPanel = 'cards'
210 | infocard_overlay.style.display = 'flex'
211 | dark_overlay.classList.add('dark_overlay')
212 | } else {
213 | // close it
214 | openedPanel = null
215 | infocard_overlay.style.display = 'none'
216 | document.getElementById('infocard_stack').innerHTML = ''
217 | dark_overlay.classList.remove('dark_overlay')
218 |
219 | // Here, we could nullize the localBuffer so it is not falsely used by some other instance. When doing so, the whole instance would have to be initiated again when reopening the stacks. Because the user could reopen the same card-stack after closing without opening a different item before, we could instead keep the created instance and only overwrite the localBuffer when the opened item is not the same as before.
220 | }
221 | }
222 |
223 |
224 | function addInfoCard(position, index) {
225 | let stack
226 | switch(position) {
227 | case 'left':
228 | stack = 'left_stack'
229 | break
230 | case 'middle':
231 | stack = 'middle_stack'
232 | break
233 | case 'right':
234 | stack = 'right_stack'
235 | break
236 | }
237 | let infocard_stack = document.getElementById('infocard_stack')
238 | infocard_stack.appendChild(generateInfoCardDummy(stack, index))
239 | }
240 |
241 | function generateInfoCardDummy(stack, index) {
242 | let infocard = document.createElement('div')
243 | infocard.classList = 'infocard shadow_b '+stack
244 | infocard.id = 'card_'+index
245 | infocard.innerHTML = `
246 |
253 |
254 |
255 |

256 |
257 |
258 | `
259 | return infocard
260 | }
261 |
262 | function generateInfoCardContent(updates) {
263 | return`
264 |
265 |
266 |
![]()
267 |
268 |
269 |
270 |
271 |
272 |
273 |

274 |
${updates.ratingPercent}%
275 |
276 |
277 |
278 | ${updates.seasonNumber}x${updates.episodeNumber} ${updates.episodeTitle}
279 |
280 |
281 |
282 |

283 |
288 |
289 |
${updates.description}
290 |
291 |
292 | `
293 | }
294 |
295 | function generateStackSlider() {
296 | let slider = document.getElementById('indicator_slider')
297 | let stacks = getCardStacks()
298 | let totalSize = stacks.left.length
299 | + stacks.middle.length // will always be 1, this makes it understandable
300 | + stacks.right.length
301 |
302 | // Here, we could check if there are more than one items, but its okay to show a single red bar for now.
303 | // set the width of the thump to match the ratio
304 | let sliderWidth = slider.offsetWidth/totalSize
305 | if(sliderWidth < 15) {
306 | sliderWidth = 15 // fix width to height, so it stays visible
307 | }
308 |
309 | let styler = document.querySelector('[data="indicator"]')
310 | styler.innerHTML = `
311 | #indicator input::-webkit-slider-thumb {
312 | width: ${sliderWidth}px !important;
313 | }
314 | `
315 | // set position of the thumb
316 | slider.min = 1;
317 | slider.max = totalSize
318 | slider.value = stacks.left.length+1
319 | }
320 |
321 | function updateInfoCard(itemUpdates, index) {
322 | let stacks = getCardStacks()
323 | debugLog('updating card', index)
324 |
325 | let theStack
326 |
327 | if(index < stacks.left.length) {
328 | theStack = stacks.left[index]
329 | } else if(index == stacks.left.length) {
330 | theStack = stacks.middle[0]
331 | } else {
332 | theStack = stacks.right[index - stacks.left.length-1]
333 | }
334 |
335 | if(theStack !== undefined) {
336 | theStack.innerHTML = generateInfoCardContent(itemUpdates)
337 | } else {
338 | debugLog('!updating card', 'failed, could not find element')
339 | }
340 | }
341 |
342 | function updateInfoCardImage(url, index) {
343 | let stacks = getCardStacks()
344 | debugLog('updating card images', index)
345 |
346 | let theCard
347 |
348 | if(index < stacks.left.length) {
349 | theCard = stacks.left[index]
350 | } else if(index == stacks.left.length) {
351 | theCard = stacks.middle[0]
352 | } else {
353 | theCard = stacks.right[index - stacks.left.length-1]
354 | }
355 |
356 | /** url:
357 | * banner
358 | * poster
359 | * actors[]
360 | */
361 | if(theCard !== undefined) {
362 | theCard.querySelector('.infocard_banner img').src = url.banner
363 | theCard.querySelector('.infocard_poster img').src = url.poster
364 | } else {
365 | debugLog('!updating card', 'failed, could not find element')
366 | }
367 | }
368 |
369 |
370 | /*::::::::::::::::::::::::::::::::::::::::::::::: SIDE-BAR :::::::::::::::::::::::::::::::::::::::::::::::*/
371 |
372 | // This object holds the DOM-elements and actions of the sidebar. We need this to generate the frame of the sidebar where content can be added dynamically later. Further comments explain the functioning.
373 | let sideBar = {
374 | element: document.getElementById('side_panel'),
375 | // This variable tells which sidebar is currently open. The possible values are:
376 | // 'none' | 'search' | 'settings' | 'logout'
377 | status: 'none',
378 | // These are the available panels
379 | panels: ['search', 'settings', 'logout'],
380 | // Now these are the panel creators
381 | search: {
382 | create: function() {
383 | let panel = document.createElement('div')
384 | panel.classList.add('panel')
385 |
386 | let search_field = document.createElement('input')
387 | search_field.classList.add('panel_header', 'search', 'fs23', 'fw500', 'white_t', 'black_d_b', 'z4')
388 | search_field.type = 'text'
389 | search_field.onkeydown = function() {
390 | if(event.keyCode === 13) { // ENTER
391 | search(search_field.value)
392 | return false
393 | }
394 | }
395 |
396 | setTimeout(() => {
397 | search_field.focus()
398 | }, 220)
399 | panel.appendChild(search_field)
400 |
401 | let box = document.createElement('div')
402 | box.classList.add('panel_header_box', 'top', 'z3')
403 | panel.appendChild(box)
404 |
405 | let gradient = document.createElement('div')
406 | gradient.classList.add('panel_header_gradient', 'top_p', 'z3')
407 | panel.appendChild(gradient)
408 |
409 | let results = document.createElement('div')
410 | results.classList.add('side_panel_list')
411 | results.id = 'results'
412 | panel.appendChild(results)
413 |
414 | return panel
415 | },
416 | open: function() {
417 | this.parent.appendChild(this.create())
418 | }
419 | },
420 | settings: {
421 | create: function() {
422 | let panel = document.createElement('div')
423 | panel.classList.add('panel')
424 |
425 | let headText = document.createElement('h2')
426 | headText.classList.add('panel_header', 'fs23', 'fw500', 'white_t', 'z4')
427 | headText.innerText = 'Settings'
428 | panel.appendChild(headText)
429 |
430 | let box = document.createElement('div')
431 | box.classList.add('panel_header_box', 'top', 'z3')
432 | panel.appendChild(box)
433 |
434 | let gradient = document.createElement('div')
435 | gradient.classList.add('panel_header_gradient', 'top_p', 'z3')
436 | panel.appendChild(gradient)
437 |
438 | let setting_list = document.createElement('div')
439 | setting_list.classList.add('side_panel_list', 'animation_slide_right')
440 |
441 | let settings = getSettings('app')
442 |
443 | let settingsArray = objectToArray(settings)
444 |
445 | delayFunction((index, arr) => {
446 | let s = arr[index].name
447 | let settingBox = addSetting(settings[s], s)
448 | setting_list.appendChild(settingBox)
449 | }, 150, getObjectLength(settings), settingsArray, 2)
450 |
451 | let relaunch_box = document.createElement('div')
452 | relaunch_box.id = 'relaunch_box'
453 | relaunch_box.innerHTML = `Some settings require a
` // rest is added below
454 | relaunch_box.classList.add('black_d_b', 'shadow_h', 'bottom', 'z4')
455 | relaunch_box.style.visibility = 'hidden'
456 |
457 | let relaunch_button = document.createElement('div')
458 | relaunch_button.innerText = 'relaunch'
459 | relaunch_button.classList.add('btn', 'red_d_b', 'white_t')
460 | relaunch_button.onclick = function() {
461 | relaunchApp()
462 | }
463 | relaunch_box.appendChild(relaunch_button)
464 |
465 | panel.appendChild(setting_list)
466 | panel.appendChild(relaunch_box)
467 | return panel
468 | },
469 | open: function() {
470 | this.parent.appendChild(this.create())
471 | }
472 | },
473 | logout: {
474 | create: function() {
475 | let panel = document.createElement('div')
476 | panel.classList.add('panel')
477 |
478 | let logout_button = document.createElement('button')
479 | logout_button.classList.add('logout_btn', 'fs18', 'white_t', 'black_d_b')
480 | logout_button.innerText = 'Logout'
481 | logout_button.onclick = function() {
482 | signout()
483 | return false
484 | }
485 | panel.appendChild(logout_button)
486 |
487 | let logout_text = document.createElement('div')
488 | logout_text.style = 'text-align:center;'
489 | logout_text.innerHTML = 'Oh uh!
You really want to do this?
'
490 | panel.appendChild(logout_text)
491 | return panel
492 | },
493 | open: function() {
494 | this.parent.appendChild(this.create())
495 | }
496 | },
497 | // Removes the panel contents from the sidebar
498 | removeAll: function() {
499 | let panels = this.element.getElementsByClassName('panel')
500 | while(panels[0]) {
501 | panels[0].parentNode.removeChild(panels[0])
502 | }
503 | },
504 | // This helper initializes the available panels by providing the sidebar element as a parent. The method is called right after the creation of this object.
505 | init: function() {
506 | this.panels.forEach(panel => {
507 | this[panel].parent = this.element
508 | })
509 | delete this.init
510 | return this
511 | }
512 | }.init()
513 |
514 |
515 | // Opens and closes the given panel
516 | function triggerSidePanel(panelName) {
517 | // Checking if panel is available. This will not be accessible by the user directly, so we could live without the check but for possible future changes it's safer to have and not wonder about weird errors
518 | if(!sideBar.panels.includes(panelName)) {
519 | throw 'panel not available'
520 | }
521 |
522 | let overlay = document.getElementById('overlay')
523 | let side_buttons = document.getElementById('side_buttons')
524 | let side_panel = document.getElementById('side_panel')
525 |
526 | if(sideBar.status == 'none') {
527 | sideBar.status = panelName
528 | openedPanel = 'sidebar'
529 |
530 | // fading out the background
531 | overlay.classList.add('show')
532 |
533 | // now showing the settings panel
534 | side_panel.classList.remove('side_panel_animate_out')
535 | side_panel.classList.add('side_panel_animate_in')
536 | side_buttons.classList.remove('side_buttons_animate_out')
537 | side_buttons.classList.add('side_buttons_animate_in')
538 |
539 | sideBar[panelName].open()
540 | }
541 |
542 | // When the panel-button of the currently opened panel was clicked, the whole sidebar will close
543 | else if(sideBar.status == panelName) {
544 | if(sideBar.status == 'search') {
545 | removeSearchResults()
546 | }
547 | sideBar.status = 'none'
548 | openedPanel = null
549 |
550 | // removing the settings panel
551 | side_panel.classList.remove('side_panel_animate_in')
552 | side_panel.classList.add('side_panel_animate_out')
553 | side_buttons.classList.remove('side_buttons_animate_in')
554 | side_buttons.classList.add('side_buttons_animate_out')
555 |
556 | // fading in the background
557 | overlay.style.display = 'block'
558 | overlay.classList.remove('show')
559 | // the timeout makes a fadeout animation possible
560 | setTimeout(() => {
561 | overlay.style.display = 'none'
562 | sideBar.removeAll()
563 | }, 220)
564 |
565 | // re-highlight the search button
566 | show(document.getElementById('search_button_side'))
567 | }
568 |
569 | // When another button than the currently open panel was clicked, the sidebar stays open and changes it's content
570 | else {
571 | sideBar.status = panelName
572 | sideBar.removeAll()
573 | sideBar[panelName].open()
574 | }
575 | }
576 |
577 | // These functions are called by onclicks in the HTML
578 | function openSearch() {
579 | triggerSidePanel('search')
580 | }
581 |
582 | function openSettings() {
583 | triggerSidePanel('settings')
584 | }
585 |
586 | function openLogout() {
587 | triggerSidePanel('logout')
588 | }
589 |
590 | function closeSidePanel() {
591 | try {
592 | triggerSidePanel(sideBar.status)
593 | } catch(err) {
594 | debugLog('error', err)
595 | }
596 | }
597 |
598 |
599 | /*::::::::::::::::::::::::::::::::::::::::::::::: SETTINGS PANEL :::::::::::::::::::::::::::::::::::::::::::::::*/
600 | let wantsRelaunch = []
601 |
602 | // This adds a setting box to the sidepanel
603 | function addSetting(setting, name) {
604 | let setting_area = document.createElement('div')
605 | setting_area.classList.add('setting_holder')
606 |
607 | let setting_title = document.createElement('h3')
608 | setting_title.classList.add('fs18', 'fw500', 'tu', 'tOverflow')
609 | setting_title.innerText = name
610 |
611 | let settingOld = setting.status
612 |
613 | function alertRequiredReload(settingNew) {
614 | let relaunch_box = document.getElementById('relaunch_box')
615 | let setting_list = document.getElementById('side_panel')
616 | let panel = setting_list.children[0]
617 |
618 | if(settingNew !== settingOld) {
619 | wantsRelaunch.push(name)
620 | relaunch_box.style = 'visiblity:visible;'
621 | relaunch_box.classList.remove('animation_fade_out')
622 | relaunch_box.classList.add('animation_slide_up')
623 | panel.children[3].classList.add('relaunch')
624 | let pos = panel.scrollTop
625 | panel.scrollTop = pos+200
626 | } else {
627 | wantsRelaunch = wantsRelaunch.filter(item => item !== name)
628 | if(wantsRelaunch.length === 0) {
629 | relaunch_box.classList.remove('animation_slide_up')
630 | relaunch_box.classList.add('animation_fade_out')
631 | panel.children[3].classList.remove('relaunch')
632 | }
633 | }
634 | debugLog('relaunch required', wantsRelaunch)
635 | }
636 |
637 | switch(setting.type) {
638 | case 'select': {
639 | classname = 'setting_select'
640 |
641 | for(let o in setting.options) {
642 | let opt = setting.options[o]
643 |
644 | let setting_contain = document.createElement('div')
645 | setting_contain.classList.add('setting_container')
646 |
647 | let preview = document.createElement('div')
648 | preview.classList.add('setting_box')
649 |
650 | let def = document.createElement('div')
651 | def.classList.add('setting_def', 'fs14' , 'white_d_t', 'tu', 'tOverflow')
652 |
653 | if(setting.default == o) {
654 | def.innerText = 'default'
655 | }
656 |
657 | if(setting.status == o) {
658 | preview.classList.add('selected')
659 | }
660 |
661 | preview.onclick = function() {
662 | if(!preview.classList.contains('selected')) {
663 | let par = preview.parentElement.parentElement;
664 | [...par.children].forEach(element => {
665 | if(element.children[0] == preview) {
666 | preview.classList.add('selected')
667 | setSetting('app', name, o)
668 | updateApp()
669 | } else {
670 | element.children[0].classList.remove('selected')
671 | }
672 | })
673 | }
674 | }
675 |
676 | if(opt.preview) {
677 | def.classList.add('top')
678 | setting_contain.classList.add('wide')
679 | preview.style.backgroundImage = `url('../../assets/previews/${opt.value}')`
680 | } else {
681 | setting_area.style = 'display:flex;justify-content:space-between;'
682 | preview.style.backgroundColor = opt.value
683 | }
684 |
685 | setting_contain.appendChild(preview)
686 | setting_contain.appendChild(def)
687 | setting_area.appendChild(setting_contain)
688 | }
689 | break
690 | }
691 | case 'toggle': {
692 | classname = 'setting_toggle'
693 | let check_no = ''
694 | let check_yes = ''
695 | if(setting.status) {
696 | check_yes = 'checked'
697 | } else {
698 | check_no = 'checked'
699 | }
700 |
701 | let idname = name.split(' ').join('_')
702 |
703 | let toggle_switch = document.createElement('div')
704 | toggle_switch.innerHTML = `
705 |
706 |
707 |
708 |
711 |
714 |
715 | `
716 |
717 | toggle_switch.onclick = function() {
718 | let radio = document.getElementById(`yes_${idname}`)
719 | alertRequiredReload(radio.checked)
720 | setSetting('app', name, radio.checked)
721 | updateApp()
722 | }
723 |
724 | let def = document.createElement('div')
725 | def.classList.add('setting_def', 'fs14' , 'white_d_t', 'tu')
726 |
727 | def.innerText = 'default: '
728 | if(setting.default) {
729 | def.innerText += 'on'
730 | } else {
731 | def.innerText += 'off'
732 | }
733 |
734 | setting_area.style = 'display:flex;justify-content:space-between;align-items:center;'
735 | setting_area.appendChild(toggle_switch)
736 | setting_area.appendChild(def)
737 | break
738 | }
739 | case 'range': {
740 | classname = 'setting_range'
741 |
742 | let slider = document.createElement('input')
743 | slider.type = 'range'
744 | slider.min = setting.range[0] / setting.accuracy
745 | slider.max = setting.range[1] / setting.accuracy
746 | slider.value = setting.status / setting.accuracy
747 | slider.style.background = `linear-gradient(to right, var(--accent_color) 0%, var(--accent_color) ${setting.status}%, var(--white_d) ${setting.status}%, var(--white_d) 100%)`;
748 | slider.classList.add('slider')
749 |
750 | slider.oninput = function() {
751 | let value = slider.value * setting.accuracy
752 | slider.style.background = 'linear-gradient(to right, var(--accent_color) 0%, var(--accent_color) '+value +'%, var(--white_d) ' + value + '%, var(--white_d) 100%)'
753 | setSetting('app', name, value)
754 | updateApp()
755 | }
756 |
757 | let def = document.createElement('div')
758 | def.classList.add('setting_def', 'fs14' , 'white_d_t', 'tu')
759 |
760 | def.innerText = 'default: ' + setting.default
761 |
762 | setting_area.appendChild(slider)
763 | setting_area.appendChild(def)
764 | break
765 | }
766 | default: { break }
767 | }
768 |
769 | let box = document.createElement('div')
770 | box.classList.add('panel_box', 'panel_box_container', 'setting')
771 |
772 | box.appendChild(setting_title)
773 | box.appendChild(setting_area)
774 | return box
775 | }
776 |
777 | /*::::::::::::::::::::::::::::::::::::::::::::::: SEARCH-PANEL :::::::::::::::::::::::::::::::::::::::::::::::*/
778 | let searchHistoryCache = new Cache('searchHistory')
779 |
780 | // This gets fired when the user searches something from the sidebar
781 | async function search(text) {
782 | let requestTime = Date.now()
783 | removeSearchResults()
784 |
785 | if(text == '') {
786 | // empty search submitted
787 | return false
788 | }
789 |
790 | let cacheContent = searchHistoryCache.getKey(text)
791 | if(cacheContent !== undefined) {
792 | // add cached search results
793 | }
794 |
795 | let data = await searchRequestHelper(text).then(res => res)
796 | debugLog('request finished', data.date)
797 |
798 | data.result.forEach(item => {
799 | debugLog('search', `adding result ${item.trakt[item.trakt.type].ids.trakt} (${item.trakt.score})`)
800 | // fallback for unavailable images
801 | let img = url = '../../assets/'+config.client.placeholder.search
802 |
803 | if(item.fanart !== undefined) {
804 | if(item.fanart.hasOwnProperty('tvposter')) {
805 | img = item.fanart.tvposter[0].url
806 | } else if(item.fanart.hasOwnProperty('movieposter')) {
807 | img = item.fanart.movieposter[0].url
808 | }
809 | }
810 |
811 | // render search result
812 | addSearchResult({
813 | title: item.trakt[item.trakt.type].title,
814 | type: item.trakt.type,
815 | rating: Math.round(item.trakt[item.trakt.type].rating * 10),
816 | img: img,
817 | description: item.trakt[item.trakt.type].overview,
818 | id: item.trakt[item.trakt.type].ids.tmdb
819 | })
820 | })
821 |
822 | debugLog('time taken', Date.now()-requestTime+'ms')
823 | }
824 |
825 | // This function generates a html element for one search result and adds it to the sidebar.
826 | function addSearchResult(result) {
827 | let panel = document.getElementById('results')
828 | let panel_box = document.createElement('div')
829 | panel_box.classList.add('panel_box', 'search', 'animation_slide_right')
830 |
831 | let poster_img = document.createElement('img')
832 | poster_img.classList.add('poster')
833 | poster_img.src = result.img
834 |
835 | let panel_box_container = document.createElement('div')
836 | panel_box_container.classList.add('panel_box_container')
837 |
838 | let h3 = document.createElement('h3')
839 | h3.classList.add('fs18', 'tOverflow')
840 | h3.innerText = result.title
841 |
842 | let p = document.createElement('p')
843 | p.classList.add('tOverflow', 'normal')
844 | p.innerText = result.description
845 |
846 | let poster_content = document.createElement('div')
847 | poster_content.classList.add('poster-content')
848 |
849 | let poster_content_left = document.createElement('div')
850 | poster_content_left.classList.add('poster-content-left')
851 |
852 | let heart = document.createElement('img')
853 | heart.src = '../../assets/icons/app/heart.svg'
854 |
855 | let span = document.createElement('span')
856 | span.classList.add('fs16')
857 | span.innerText = result.rating + "%"
858 |
859 | let poster_content_right = document.createElement('div')
860 | poster_content_right.classList.add('poster-content-right')
861 | poster_content_right.append(...createActionButtons(result.id))
862 |
863 | poster_content_left.appendChild(heart)
864 | poster_content_left.appendChild(span)
865 |
866 | poster_content.appendChild(poster_content_left)
867 | poster_content.appendChild(poster_content_right)
868 |
869 | panel_box_container.appendChild(h3)
870 | panel_box_container.appendChild(p)
871 | panel_box_container.appendChild(poster_content)
872 |
873 | panel_box.appendChild(poster_img)
874 | panel_box.appendChild(panel_box_container)
875 |
876 | panel.appendChild(panel_box)
877 | }
878 |
879 | // Removes all elements from the search panel in the sidebar
880 | function removeSearchResults() {
881 | let panel = document.getElementById('results')
882 | boxes = panel.getElementsByClassName('panel_box search')
883 | while(boxes[0]) {
884 | boxes[0].parentNode.removeChild(boxes[0])
885 | }
886 | }
887 |
888 |
889 | /*::::::::::::::::::::::::::::::::::::::::::::::: UP-NEXT-TO-WATCH :::::::::::::::::::::::::::::::::::::::::::::::*/
890 | // This gets fired when the dashboard is loaded
891 | async function generatePosterSection(update) {
892 | let requestTime = Date.now()
893 |
894 | let data = await getUnfinishedProgressList(5, update)
895 |
896 | if(update) {
897 | // clear dashboard
898 | document.querySelector('#dash').innerHTML = `
899 |
900 | `
901 | }
902 |
903 | data.forEach((item, index) => {
904 | debugLog('item to add', item.show.show.title)
905 |
906 | let next = item.progress.next_episode
907 | let title = item.show.show.title
908 | let subtitle = `${next.season} x ${next.number+(next.number_abs?' ('+next.number_abs +')':'')} ${next.title}`
909 |
910 | if(index === 0) {
911 | createTitle({
912 | title: title,
913 | subtitle: subtitle
914 | })
915 | }
916 | createPoster({
917 | title: title,
918 | subtitle: subtitle,
919 | rating: next.rating,
920 | id: item.show.show.ids.tvdb,
921 | season: next.season,
922 | matcher: `${item.show.show.ids.trakt}_e_${next.season}_${next.number}`
923 | })
924 | })
925 |
926 | debugLog('time taken', Date.now()-requestTime+'ms')
927 | }
928 |
929 |
930 | async function createPoster(itemToAdd) {
931 | let li = document.createElement('li')
932 | li.classList.add('poster', 'poster-dashboard', 'shadow_h')
933 | // This is the most important one, as it will tell the rest of the app what item the poster shows. It will be used to provide information when the user clicks on this item.
934 | li.setAttribute('data_matcher', itemToAdd.matcher)
935 |
936 | li.setAttribute('data_title', itemToAdd.title)
937 | li.setAttribute('data_subtitle', itemToAdd.subtitle)
938 |
939 | li.setAttribute('onmouseover', 'animateText(this, true)')
940 | li.setAttribute('onmouseleave', 'animateText(this, false)')
941 |
942 | li.setAttribute('onclick', 'openInfoCard(this)')
943 |
944 | let poster_content = document.createElement('div')
945 | poster_content.classList.add('poster-content')
946 |
947 | let poster_content_left = document.createElement('div')
948 | poster_content_left.classList.add('poster-content-left', 'fs14', 'white_t', 'fw700')
949 |
950 | let heart = document.createElement('img')
951 | heart.src = '../../assets/icons/app/heart.svg'
952 |
953 | let rate = document.createElement('span')
954 | rate.innerText = `${Math.round(itemToAdd.rating * 10)}%`
955 |
956 | poster_content_left.appendChild(heart)
957 | poster_content_left.appendChild(rate)
958 |
959 | let poster_content_right = document.createElement('div')
960 | poster_content_right.classList.add('poster-content-right')
961 | poster_content_right.append(...createActionButtons(itemToAdd.id))
962 |
963 | poster_content.appendChild(poster_content_left)
964 | poster_content.appendChild(poster_content_right)
965 |
966 | li.appendChild(poster_content)
967 |
968 | requestAndLoadImage({
969 | parent: li,
970 | use: 'poster',
971 | type: 'season',
972 | itemId: itemToAdd.id,
973 | reference: itemToAdd.season
974 | })
975 |
976 | let posters = document.getElementById('posters')
977 | posters.appendChild(li);
978 | }
979 |
980 | function createTitle(itemToAdd) {
981 | let title = document.getElementById('poster_title')
982 |
983 | let h3 = document.createElement('h3')
984 | h3.classList.add('h3', 'red_t', 'tu')
985 | h3.innerText = 'up next to watch'
986 |
987 | let h1 = document.createElement('h1')
988 | h1.classList.add('h1', 'white_t', 'tu', 'tOverflow')
989 | h1.innerText = itemToAdd.title
990 |
991 | let h1_2 = document.createElement('h1')
992 | h1_2.classList.add('h1', 'white_d_t', 'tOverflow')
993 | h1_2.innerText = itemToAdd.subtitle
994 |
995 | title.appendChild(h3)
996 | title.appendChild(h1)
997 | title.appendChild(h1_2)
998 | }
999 |
1000 | function animateText(textBox, onenter) {
1001 | let container = document.getElementById('poster_title')
1002 | let container_title = container.children[1]
1003 | let container_subtitle = container.children[2]
1004 |
1005 | let title = textBox.getAttribute('data_title')
1006 | let subtitle = textBox.getAttribute('data_subtitle')
1007 |
1008 | if(title.toLowerCase() !== container_title.innerText.toLowerCase()) {
1009 | if(onenter) {
1010 | toggleAnimation(container_title, 'animation_slide_up', title)
1011 | toggleAnimation(container_subtitle, 'animation_slide_up', subtitle)
1012 | }
1013 | }
1014 |
1015 | let poster = document.getElementById('posters').firstChild
1016 | let poster_title = poster.getAttribute('data_title')
1017 | let poster_subtitle = poster.getAttribute('data_subtitle')
1018 |
1019 | if(poster_title.toLowerCase() !== container_title.innerText.toLowerCase()) {
1020 | if(!onenter) {
1021 | toggleAnimation(container_title, 'animation_slide_up', poster_title)
1022 | toggleAnimation(container_subtitle, 'animation_slide_up', poster_subtitle)
1023 | }
1024 | }
1025 | }
1026 |
1027 | function toggleAnimation(x, y, z) {
1028 | x.classList.remove(y)
1029 | void x.offsetWidth
1030 | x.innerText = z
1031 | x.classList.add(y)
1032 | }
1033 |
1034 | function openInfoCard(poster) {
1035 | // __[season]_[episode]
1036 | let matcher = poster.getAttribute('data_matcher')
1037 | debugLog('info card', matcher)
1038 | matcher = matcher.split('_')
1039 |
1040 | let showId = matcher[0]
1041 |
1042 | switch(matcher[1]) {
1043 | case 'e': { // episode
1044 | let seasonNum = matcher[2]
1045 | let episodeNum = matcher[3]
1046 |
1047 | let onCallbacks = {
1048 | size: epPosition => {
1049 | // remove possibly existing dummies that were used as a loading indicator
1050 | document.getElementById('infocard_stack').innerHTML = ''
1051 |
1052 | let leftStackSize = epPosition.current - 1
1053 | let rightStackSize = epPosition.total - epPosition.current
1054 |
1055 | let i = 0
1056 |
1057 | for(i; i { // onFirst
1070 | // find index of the middle card
1071 | let index = getCardStacks().left.length
1072 | updateInfoCard(epData, index)
1073 | updateLeftRightButtons()
1074 | },
1075 | buffer: (bufferData, pos) => { // onBuffer
1076 | updateInfoCard(bufferData, pos)
1077 | },
1078 | images: (urls, pos) => { // onImage
1079 | updateInfoCardImage(urls, pos)
1080 | }
1081 | }
1082 |
1083 | if(localBuffer instanceof showBuffer && localBuffer.id == showId) {
1084 | localBuffer.openAt(seasonNum, episodeNum, onCallbacks)
1085 | } else {
1086 | localBuffer = new showBuffer(showId)
1087 | localBuffer.initAt(seasonNum, episodeNum, onCallbacks)
1088 | }
1089 |
1090 | break
1091 | }
1092 | }
1093 |
1094 | triggerInfoCardOverlay()
1095 | }
1096 |
1097 | /*::::::::::::::::::::::::::::::::::::::::::::::: RPC :::::::::::::::::::::::::::::::::::::::::::::::*/
1098 | async function updateRpc() {
1099 | if(config.client.settings.app['discord rpc'].status) {
1100 | let settings = await createRpcContent()
1101 | let stateArray = config.client.rpc.states
1102 | settings.state = pick(stateArray)
1103 | rpc.update(settings)
1104 | setInterval(() => {
1105 | settings.state = pick(stateArray)
1106 | rpc.update(settings)
1107 | }, 60e3)
1108 | }
1109 | }
1110 |
1111 | async function createRpcContent() {
1112 | let stats = await getUserStats() // from request module
1113 | return {
1114 | time: {
1115 | movies: stats.movies.minutes,
1116 | shows: stats.episodes.minutes
1117 | }
1118 | }
1119 | }
1120 |
1121 | /*::::::::::::::::::::::::::::::::::::::::::::::: ACTION BUTTONS :::::::::::::::::::::::::::::::::::::::::::::::*/
1122 | function createActionButtons(item) {
1123 | let playNow = document.createElement('div')
1124 | playNow.classList.add('action_btn', 'play')
1125 | playNow.innerHTML = '
'
1126 | playNow.setAttribute('onclick', `playNow(${item})`)
1127 |
1128 | let addToList = document.createElement('div')
1129 | addToList.classList.add('action_btn', 'list')
1130 | addToList.innerHTML = '
'
1131 | addToList.setAttribute('onclick', `addToWatchlist(${item})`)
1132 |
1133 | let addToHistory = document.createElement('div')
1134 | addToHistory.classList.add('action_btn', 'history')
1135 | addToHistory.innerHTML = '
'
1136 | addToHistory.setAttribute('onclick', `addToHistory(${item})`)
1137 |
1138 | return [playNow, addToList, addToHistory];
1139 | }
1140 |
1141 | function playNow(item) {
1142 | alert('playing now!')
1143 | }
1144 |
1145 | function addToHistory(item) {
1146 | alert('added to history!')
1147 | }
1148 |
1149 | function addToWatchlist(item) {
1150 | alert('added to watchlist!')
1151 | }
1152 |
--------------------------------------------------------------------------------
/pages/dashboard/style.css:
--------------------------------------------------------------------------------
1 | /*::::::::::::::::::::::::::::::::::::::::::::::: MAIN-COMPONENTS :::::::::::::::::::::::::::::::::::::::::::::::*/
2 | :root {
3 | --side_panel_width: 450px;
4 | --panel_padding: 40px;
5 | --search_result_height: 205px;
6 | }
7 |
8 | .wrapper.dashboard {
9 | height: 100vh;
10 | width: 100vw;
11 | }
12 |
13 | .dashboard {
14 | margin: auto;
15 | min-width: 716px;
16 | }
17 | /*:::::::::::::::::::::::::::::::::::::::::::::::: ACTION-BUTTTONS :::::::::::::::::::::::::::::::::::::::::::::::*/
18 | .action_btn {
19 | vertical-align: middle;
20 | padding: 8px;
21 | position: relative;
22 | cursor: pointer;
23 | margin: 3px;
24 | border-radius: 5px;
25 | }
26 |
27 | .action_btn,
28 | .action_btn > img {
29 | display: inline-block;
30 | width: 16px;
31 | height: 19px;
32 | }
33 |
34 | .action_btn:first-child {
35 | margin-left: 0;
36 | }
37 | .action_btn:last-child {
38 | margin-right: 0;
39 | }
40 |
41 | .action_btn::before {
42 | position: absolute;
43 | transform: translateY(-15%) translateX(calc(-100% - 7px));
44 | opacity: 0;
45 | visibility: hidden;
46 | padding: 3px 25px;
47 | font-size: 14px;
48 | white-space: nowrap;
49 | font-weight: 200;
50 | color: var(--white);
51 | border-radius: 3px 0 0 3px;
52 | transition: all 200ms ease;
53 | z-index: 1;
54 | }
55 |
56 | .action_btn:hover::before {
57 | opacity: 1;
58 | visibility: visible;
59 | }
60 |
61 | .action_btn.play {
62 | background-color: #C61017;
63 | }
64 | .action_btn.play::before {
65 | content: 'Play Now?';
66 | background-color: rgb(153, 13, 17);
67 | }
68 |
69 | .action_btn.list {
70 | background-color: #2B678F;
71 | }
72 | .action_btn.list:before {
73 | content: 'Add to watchlist?';
74 | background-color: rgb(25, 61, 85);
75 | }
76 |
77 | .action_btn.history {
78 | background-color: #8E44AD;
79 | }
80 | .action_btn.history:before {
81 | content: 'Add to history?';
82 | background-color: rgb(106, 51, 129);
83 | }
84 | /*:::::::::::::::::::::::::::::::::::::::::::::::: CARDS :::::::::::::::::::::::::::::::::::::::::::::::*/
85 | #infocard_overlay {
86 | width: 100%;
87 | height: 100%;
88 | position: absolute;
89 | z-index: 7;
90 | display: flex;
91 | justify-content: center;
92 | align-items: center;
93 | }
94 |
95 | #infocard_stack {
96 | width: 70%;
97 | height: 70%;
98 | }
99 |
100 | .infocard {
101 | position: absolute;
102 | width: 70%;
103 | height: 70%;
104 | border-radius: 8px;
105 | background-color: var(--black);
106 | transition: ease 0.5s;
107 | }
108 |
109 | .left_stack {
110 | transform: translateX(-110%);
111 | opacity: 0;
112 | }
113 |
114 | .right_stack {
115 | transform: translateX(110%);
116 | opacity: 0;
117 | }
118 |
119 | .cardcontent {
120 | width: 100%;
121 | height: 100%;
122 | overflow-y: scroll;
123 | }
124 |
125 | .cardcontent::-webkit-scrollbar {
126 | display: none;
127 | }
128 |
129 | .infocard .banner {
130 | max-height: 200px;
131 | overflow: hidden;
132 | }
133 |
134 | .infocard .banner img {
135 | width: 100%;
136 | border-radius: 8px 8px 0 0;
137 | }
138 |
139 | .infosection {
140 | padding: 20px;
141 | width: calc(100% - 40px);
142 | z-index: 1;
143 | }
144 |
145 | .infocard .btns {
146 | display: inline-flex;
147 | position: absolute;
148 | margin: 0;
149 | right: -12px;
150 | top: -12px;
151 | }
152 |
153 | .infocard .btns li {
154 | padding: 5px;
155 | }
156 |
157 | .infocard .btns .btn {
158 | margin: 0;
159 | }
160 |
161 | .infocard .btn.icon {
162 | padding: 5px;
163 | }
164 |
165 | .infocard .btn.icon img {
166 | width: 18px;
167 | height: 18px;
168 | }
169 |
170 | #indicator {
171 | position: absolute;
172 | display: flex;
173 | justify-content: center;
174 | bottom: 4%;
175 | width: 100%;
176 | }
177 |
178 | #indicator input {
179 | -webkit-appearance: none;
180 | width: 35%;
181 | height: 3px; /* has to be odd! */
182 | border-radius: 3px;
183 | background: var(--black);
184 | outline: none;
185 | -webkit-transition: .2s;
186 | transition: opacity .2s;
187 | pointer-events: none;
188 | }
189 |
190 | #indicator input::-webkit-slider-thumb {
191 | -webkit-appearance: none;
192 | width: 30px;
193 | height: 5px;
194 | border-radius: 5px;
195 | background: var(--accent_color);
196 | }
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 | /*----------------- NEW CARDS----------------*/
207 | .infocard_child {
208 | position: relative;
209 | width: 100%;
210 | height: 100%;
211 | border-radius: 4px;
212 | overflow-y: auto;
213 | margin: auto;
214 | }
215 |
216 | .infocard_child::-webkit-scrollbar {
217 | display: none;
218 | }
219 |
220 | .infocard_padding {
221 | padding: 10px 20px 10px 250px;
222 | }
223 |
224 | .infocard_banner {
225 | height: 200px;
226 | width: 100%;
227 | position: relative;
228 | }
229 | .infocard_banner img {
230 | width: 100%;
231 | height: 100%;
232 | object-fit: cover;
233 | filter: grayscale(10%);
234 | }
235 |
236 | #infocard_close {
237 | position: absolute;
238 | right: 12px;
239 | top: 12px;
240 | width: 18px; /*visually more appealing that the actual 19px*/
241 | line-height: 0;
242 | padding: 5px;
243 | border-radius: 5px;
244 | display: inline-block;
245 | text-align: center;
246 | vertical-align: middle;
247 | cursor: pointer;
248 | transition: opacity 200ms ease;
249 | }
250 |
251 | #infocard_close:hover {
252 | opacity: 0.8;
253 | }
254 |
255 | #infocard_close img {
256 | filter: invert(100%);
257 | width: 16px;
258 | height: 19px;
259 | }
260 |
261 |
262 | .infocard_titles {
263 | height: 55px;
264 | }
265 | .infocard_titles * {
266 | display: inline-block;
267 | vertical-align: middle;
268 | }
269 |
270 | .infocard_titles .vertical_border {
271 | margin: 0 20px;
272 | }
273 |
274 | .infocard_titles .rating {
275 | line-height: 24px;
276 | width: 40px;
277 | height: 55px;
278 | text-align: center;
279 | white-space: normal;
280 | }
281 | .infocard_titles .rating > img {
282 | width: 30px;
283 | height: auto;
284 | }
285 |
286 | .infocard_description {
287 | opacity: 0.8;
288 | margin: 0 0 0 2px;
289 | }
290 |
291 | .infocard_poster {
292 | position: absolute;
293 | transform: translateX(30px) translateY(-180px);
294 | width: 190px;
295 | }
296 | .infocard_poster > img {
297 | width: 100%;
298 | height: 250px;
299 | object-fit: cover;
300 | border-radius: 4px;
301 | }
302 | /*:::::::::::::::::::::::::::::::::::::::::::::::: UP-NEXT-TO-WATCH :::::::::::::::::::::::::::::::::::::::::::::::*/
303 | .titles {
304 | margin-left: 20px;
305 | transform: translateX(236px) translateY(10px);
306 | height: 0;
307 | overflow: visible;
308 | }
309 |
310 | .titles h1,
311 | .titles h3 {
312 | margin: 0;
313 | line-height: 36px;
314 | max-width: 625px;
315 | }
316 |
317 | .titles h1:last-child {
318 | line-height: 39px;
319 | }
320 |
321 | .poster-dashboard:nth-child(n+6) {
322 | display: none;
323 | }
324 | .poster-dashboard:first-child {
325 | width: 236px;
326 | height: 337px;
327 | }
328 | .poster-dashboard:last-child {
329 | margin-right: 0;
330 | }
331 | .poster-dashboard::before {
332 | content: '';
333 | display: block;
334 | position: absolute;
335 | border-radius: 0 0 5px 5px;
336 | height: 0%;
337 | width: 100%;
338 | bottom: 0;
339 | transition: height 400ms ease;
340 | background-image: linear-gradient(
341 | to bottom,
342 | rgba(255, 255, 255, 0) 0, #000000e0 80%
343 | );
344 | }
345 | .poster-dashboard:hover::before {
346 | height: 60%;
347 | }
348 |
349 | .poster-content {
350 | position: absolute;
351 | bottom: 0;
352 | display: flex;
353 | justify-content: space-between;
354 | padding: 10px 12px;
355 | opacity: 0;
356 | width: calc(100% - 24px);
357 | flex-direction: column;
358 | align-items: center;
359 | transition: all 400ms ease;
360 | }
361 | .poster-dashboard:first-child > .poster-content {
362 | flex-direction: row;
363 | }
364 | .poster-dashboard:hover > .poster-content {
365 | opacity: 1;
366 | }
367 |
368 | .poster-content-left,
369 | .poster-content-right {
370 | display: inline-block;
371 | vertical-align: middle;
372 | }
373 | .poster-content-left img,
374 | .poster-content-left span {
375 | vertical-align: middle;
376 | }
377 | .poster-content-right {
378 | cursor: pointer;
379 | letter-spacing: 0.5px;
380 | }
381 | .poster-content-left img {
382 | width: 25px;
383 | height: auto;
384 | margin-right: 4px;
385 | }
386 |
387 | .dark_overlay {
388 | display: block !important;
389 | animation: blend_in 0.15s !important;
390 | animation-fill-mode: forwards !important;
391 | }
392 |
393 | #dash_overlay {
394 | top: 0;
395 | left: 0;
396 | height: 100vh;
397 | width: 100vw;
398 | display: none;
399 |
400 | position: absolute;
401 | overflow: hidden;
402 | z-index: 2;
403 | background-color: rgb(20, 20, 20);
404 |
405 | opacity: 0.75;
406 | backdrop-filter: blur(8px);
407 | animation: blend_out 0.15s;
408 | animation-fill-mode: forwards;
409 | }
410 |
411 | #info_overlay {
412 | top: 0;
413 | left: 0;
414 | height: 100vh;
415 | width: 100vw;
416 | display: none;
417 |
418 | position: absolute;
419 | overflow: hidden;
420 | z-index: 6;
421 | background-color: rgb(20, 20, 20);
422 |
423 | opacity: 0.75;
424 | backdrop-filter: blur(8px);
425 | animation: blend_out 0.15s;
426 | animation-fill-mode: forwards;
427 | }
428 |
429 | /*::::::::::::::::::::::::::::::::::::::::::::::: SIDE-PANEL :::::::::::::::::::::::::::::::::::::::::::::::*/
430 | .side_panel {
431 | display: block;
432 | position: absolute;
433 | height: 100vh;
434 | width: var(--side_panel_width);
435 | background: var(--black);
436 | color: var(--white);
437 | right: calc(-1 * var(--side_panel_width));
438 | }
439 |
440 | .side_panel .relaunch {
441 | margin-bottom: 150px;
442 | }
443 |
444 | .panel {
445 | padding: var(--side_dist) var(--panel_padding);
446 | overflow: hidden auto;
447 | height: calc(100vh - var(--side_dist));
448 | scroll-behavior: smooth;
449 | }
450 |
451 | .panel_header,
452 | .panel_header_box,
453 | .panel_header_gradient {
454 | position: absolute;
455 | right: 0;
456 | width: var(--side_panel_width);
457 | }
458 |
459 | .panel_header {
460 | margin: 0;
461 | left: 0;
462 | padding: 4px var(--panel_padding);
463 | width: calc(
464 | var( --side_panel_width) - (2*(var(--panel_padding)))
465 | );
466 | }
467 |
468 | .panel_header_box {
469 | height: calc(44px + var(--side_dist) + 16px);
470 | background-color: var(--black);
471 | }
472 |
473 | .panel_header_gradient {
474 | margin-top: calc(44px + 12px);
475 | height: 16px;
476 | background-image: linear-gradient(
477 | to bottom,
478 | var(--black), rgba(0,0,0,0)
479 | );
480 | }
481 |
482 | .side_panel_list {
483 | width: calc(var(--side_panel_width) - (2*(var(--panel_padding))));
484 | margin-top: calc(44px + 10px + 16px);
485 | }
486 |
487 | .panel_box {
488 | border-radius: 5px;
489 | background-color: var(--black_d);
490 | margin-bottom: 15px;
491 | transition: all 200ms ease;
492 | }
493 |
494 | .panel_box_container {
495 | padding: 12px;
496 | position: relative;
497 | }
498 |
499 | .panel_box_container > h3 {
500 | margin: 0;
501 | }
502 |
503 | .side_panel_animate_out {
504 | right: calc(-1 * var(--side_panel_width));
505 | animation: move_out_right 0.3s !important;
506 | }
507 |
508 | .side_panel_animate_in {
509 | right: 0px !important;
510 | animation: move_in_right 0.3s !important;
511 | }
512 |
513 | .side_buttons_animate_out {
514 | right: var(--side_dist);
515 | animation: move_out_right_buttons 0.3s !important;
516 | }
517 |
518 | .side_buttons_animate_in {
519 | right: calc(var(--side_panel_width) - 22px) !important;
520 | animation: move_in_right_buttons 0.3s !important;
521 | }
522 |
523 | @keyframes move_in_right {
524 | 0% {opacity: 0;right: calc(-1 * var(--side_panel_width))}
525 | 66% {opacity: 1}
526 | 100% {opacity: 1;right: 0px}
527 | }
528 |
529 | @keyframes move_out_right {
530 | 0% {right: 0px;opacity: 1}
531 | 33% {opacity: 1}
532 | 100% {right: calc(-1 * var(--side_panel_width));opacity: 0}
533 | }
534 |
535 | @keyframes move_in_right_buttons {
536 | 0% {right: var(--side_dist)}
537 | 100% {right: calc(var(--side_panel_width) - 22px)}
538 | }
539 |
540 | @keyframes move_out_right_buttons {
541 | 0% {right: calc(var(--side_panel_width) - 22px)}
542 | 100% {right: var(--side_dist)}
543 | }
544 | /*::::::::::::::::::::::: SEARCH-COMPONENTS :::::::::::::::::::::::*/
545 | .panel_header.search {
546 | border: none;
547 | border-radius: 0 5px 5px 0;
548 | height: 44px;
549 | left: 0;
550 | padding: 0 20px 0 var(--panel_padding);
551 | width: calc(
552 | var(--side_panel_width) - (2*var(--panel_padding)) - 20px
553 | );
554 | }
555 |
556 | .panel_box.search {
557 | cursor: pointer;
558 | }
559 | .panel_box.search:hover {
560 | box-shadow: 0px 0px 34px rgba(0, 0, 0, 0.3);
561 | }
562 |
563 | .panel_box.search .poster {
564 | margin: 0;
565 | width: 40%;
566 | border-radius: 5px 0 0 5px;
567 | height: var(--search_result_height);
568 | }
569 |
570 | .panel_box.search .poster,
571 | .panel_box.search .panel_box_container {
572 | display: inline-block;
573 | vertical-align: middle;
574 | }
575 |
576 | .panel_box.search .panel_box_container {
577 | width: calc(60% - 24px);
578 | height: calc(var(--search_result_height) - 24px);
579 | }
580 |
581 | .panel_box_container > p {
582 | font-weight: 300;
583 | font-size: 13px;
584 | opacity: .60;
585 | display: -webkit-box;
586 | line-height: 18px;
587 | -webkit-line-clamp: 6;
588 | -webkit-box-orient: vertical;
589 | margin: 0;
590 | }
591 |
592 | .panel_box.search .poster-content {
593 | opacity: 1;
594 | padding: 0;
595 | flex-direction: row;
596 | bottom: 12px;
597 | }
598 |
599 | .panel_box.search .poster-content-left img {
600 | width: 20px;
601 | }
602 |
603 | #results .action_btn,
604 | #results .action_btn > img {
605 | width: 6px;
606 | }
607 | /*::::::::::::::::::::::: SETTINGS-COMPONENTS :::::::::::::::::::::::*/
608 | .panel_box.setting > h3 {
609 | margin: 0 0 10px 0;
610 | }
611 | .setting_container {
612 | display: inline-block;
613 | position: relative;
614 | text-align: center;
615 | width: 50px;
616 | vertical-align: top;
617 | margin-right: 10px;
618 | cursor: pointer;
619 | }
620 | .setting_container:last-child {
621 | margin: 0;
622 | }
623 |
624 | .setting_box {
625 | height: 50px;
626 | object-fit: cover;
627 | border: 2px solid var(--white_d);
628 | border-radius: 5px;
629 | }
630 | .setting_box.selected {
631 | border: 2px solid var(--accent_color_d)
632 | }
633 |
634 | .setting_container.wide {
635 | display: block;
636 | width: 100%;
637 | margin-bottom: 10px;
638 | }
639 | .setting_container.wide:last-child {
640 | margin-bottom: 0;
641 | }
642 | .setting_container.wide .setting_def {
643 | position: absolute;
644 | line-height: calc(50px + 4px);
645 | width: 100%;
646 | pointer-events: none;
647 | }
648 |
649 | #relaunch_box {
650 | width: calc(var(--side_panel_width) - (2*var(--side_dist)));
651 | padding: var(--side_dist);
652 | margin-left: calc(-1 * var(--side_dist) - 10px);
653 | }
654 |
655 | #relaunch_box > h3 {
656 | text-align: center;
657 | margin: 0 auto 10px;
658 | }
659 |
660 | #relaunch_box > div {
661 | width: fit-content;
662 | margin: auto;
663 | padding: 5px 20px;
664 | }
665 | /*::::::::::::::::::::::: LOGOUT-COMPONENTS :::::::::::::::::::::::*/
666 | .logout_btn {
667 | height: 44px;
668 | padding: 0 var(--panel_padding);
669 | border: none;
670 | position: absolute;
671 | top: calc(var(--side_dist) + calc(2 * 44px) + calc(2 * 8px));
672 | border-radius: 0 44px 44px 0;
673 | cursor: pointer;
674 | left: 0;
675 | }
676 | /*::::::::::::::::::::::::::::::::::::::::::::::: RESPONSIVE :::::::::::::::::::::::::::::::::::::::::::::::*/
677 | @media screen and (max-width: 952px) {
678 | :root {
679 | --side_panel_width: 380px;
680 | --search_result_height: 165px;
681 | }
682 |
683 | .titles {
684 | transform: translateX(236px) translateY(30px);
685 | }
686 | .titles h1,
687 | .titles h3 {
688 | margin: 0;
689 | line-height: 27px;
690 | max-width: 450px;
691 | }
692 | .titles h1:last-child {
693 | line-height: 34px;
694 | }
695 |
696 | .poster-dashboard:nth-child(4) {
697 | margin-right: 0;
698 | }
699 | .poster-dashboard:nth-child(5) {
700 | display: none;
701 | }
702 |
703 | .side_panel .relaunch {
704 | margin-bottom: 130px;
705 | }
706 |
707 | #relaunch_box {
708 | margin-left: calc(-1 * var(--side_dist) - 20px);
709 | }
710 |
711 | #results .panel_box_container > p {
712 | display: none;
713 | }
714 | }
715 |
--------------------------------------------------------------------------------
/pages/loading/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Loading...
8 |
9 |
10 |
23 |
24 |
25 |
26 |

27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/pages/loading/index.js:
--------------------------------------------------------------------------------
1 | // Required for loading! Do not remove the following constants even if they say to be unused!
2 | const trakt = remote.getGlobal('trakt')
3 | const relaunchApp = remote.getGlobal('relaunchApp')
4 |
5 |
6 | let loadingTime = Date.now()
7 |
8 | function alertChecker() {
9 | setTimeout(() => {
10 | let timeTaken = Date.now()-loadingTime
11 | debugLog('loading check', timeTaken)
12 |
13 | if(timeTaken > 12e3) {
14 | debugLog('loading', 'too long')
15 | showAlertBox()
16 | } else {
17 | alertChecker()
18 | }
19 | }, 1e3)
20 | }
21 |
22 | alertChecker()
23 |
24 |
25 | new Promise((resolve, rej) => {
26 | resolve(ipcRenderer.sendSync('loading-screen', 'loaded'))
27 | }).then(async res => {
28 | if(res === 'start') {
29 | debugLog('loading', 'started')
30 |
31 | debugLog('loading', 'activities')
32 | let activities = await newActivitiesAvailable()
33 |
34 | debugLog('loading', 'up next to watch')
35 | let upNext = await getUnfinishedProgressList(5)
36 |
37 | debugLog('loading', 'done')
38 | setTimeout(() => {
39 | // telling the app to move on, we need this to trigger the opening of the dashboard page
40 | ipcRenderer.send('loading-screen', 'done')
41 | }, 33.3) // giving some small extra timeout
42 | } else {
43 | debugLog('error', res, new Error().stack)
44 | }
45 | })
46 |
47 |
48 | function showAlertBox() {
49 | let alert_box = document.createElement('div')
50 | alert_box.classList.add('alert_box', 'white_t')
51 | alert_box.innerHTML = `
52 |
53 | Man, this takes long!
54 |
55 | Try relaunching or reach out to us here to get help.
56 |
57 | `
58 | document.body.appendChild(alert_box)
59 | }
60 |
--------------------------------------------------------------------------------
/pages/loading/style.css:
--------------------------------------------------------------------------------
1 | .absolute {
2 | position: absolute;
3 | top: 0;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | transform: translateY(calc(50% - 120px));
8 | }
9 |
10 | .logo {
11 | height: 180px;
12 | width: 180px;
13 | margin: auto;
14 | display: block;
15 | }
16 |
17 | .alert_box {
18 | text-align: center;
19 | position: absolute;
20 | width: 100vw;
21 | display: flex;
22 | justify-content: center;
23 | top: 60%;
24 | animation: fly_in_alert 0.6s;
25 | }
26 |
27 | .alert_box span {
28 | cursor: pointer;
29 | }
30 |
31 | @keyframes fly_in_alert {
32 | 0% {
33 | transform: translateY(50px) scale(0.8);
34 | opacity: 0;
35 | }
36 | 65% {
37 | transform: translateY(0) scale(1.1);
38 | opacity: 1;
39 | }
40 | 100% {
41 | transform: scale(1);
42 | }
43 | }
--------------------------------------------------------------------------------
/pages/login/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 | Traktify | Login
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |

20 |
traktify
a trakt.tv desktop app
21 |
22 |
Sign in to Trakt
23 |
24 |
25 | sign in
26 |
27 |
28 | We've already copied the code into your clipboard.
29 |
30 | You just have to paste it in :)
31 |
32 |
33 | Need the code again?
34 |
35 |
36 |
37 | Buy us a coffee?
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/pages/login/index.js:
--------------------------------------------------------------------------------
1 | function codeToClipboard() {
2 | remote.getGlobal('codeToClipboard')()
3 | }
4 |
5 | function authenticate() {
6 | document.getElementById('sign_in_btn').style.display = 'none'
7 | document.getElementById('sign_in_alert').style.display = 'block'
8 | document.getElementById('get_code_again').style.display = 'block'
9 | remote.getGlobal('authenticate')()
10 | }
11 |
--------------------------------------------------------------------------------
/pages/login/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --accent_color: #ED1C24;
3 | --accent_color_d: #BD161C;
4 | --background_image: url('../assets/furniture.png');
5 | --background_opacity : 1;
6 | }
7 |
8 | .wrapper.login {
9 | padding: 0;
10 | min-height: 100vh;
11 | }
12 |
13 | .login {
14 | padding: 100px;
15 | text-align: center;
16 | margin: auto;
17 | border-radius: 17px;
18 | }
19 |
20 | .login h1 {
21 | font-size: 32px;
22 | }
23 |
24 | .logo img {
25 | width: 213px;
26 | height: 213px;
27 | margin-bottom: 35px;
28 | }
29 | .logo div {
30 | font-size: 50px;
31 | line-height: 20px;
32 | }
33 | .logo div span {
34 | font-size: 15px;
35 | letter-spacing: 1.7px;
36 | }
37 |
38 | @media screen and (max-height: 650px) {
39 | .login {
40 | display: flex;
41 | flex-flow: column;
42 | align-items: center;
43 | justify-content: center;
44 | width: -webkit-fill-available;
45 | height: -webkit-fill-available;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/pages/main.js:
--------------------------------------------------------------------------------
1 | if(!remote.getGlobal('darwin')) {
2 | let dragger = document.getElementById('dragger')
3 | if(dragger !== null) {
4 | dragger.remove()
5 | }
6 | }
7 |
8 |
9 | //:::: GLOBAL FUNCTIONS ::::\\
10 |
11 | function signout() {
12 | remote.getGlobal('disconnect')()
13 | }
14 |
15 | function openDonate() {
16 | remote.getGlobal('openExternal')('https://buymeacoff.ee/CodingBobby')
17 | }
18 |
19 | // TODO: Reloading should only fetch latest changes without reloading the html
20 | function reload() {
21 | remote.getGlobal('loadDashboard')()
22 | }
23 |
24 | function debugLog(...args) {
25 | remote.getGlobal('debugLog').apply(null, args)
26 | }
27 |
28 |
29 | //:::: HELPERS ::::\\
30 |
31 | // inserts an element before another one
32 | function insertBefore(element, reference) {
33 | reference.parentNode.insertBefore(element, reference)
34 | }
35 |
36 | // adds multiple css styles to an element, example:
37 | // styles: { display: 'block', backgroundColor: 'red' }
38 | function css(element, styles) {
39 | for(let property in styles) {
40 | // just a security check
41 | if({}.hasOwnProperty.call(styles, property)) {
42 | element.style[property] = styles[property]
43 | }
44 | }
45 | }
46 |
47 | // returns a random item from a given array
48 | function pick(array) {
49 | return array[Math.floor(Math.random() * array.length)]
50 | }
51 |
52 | // works as Array.prototype.length
53 | function getObjectLength(obj) {
54 | let len = 0
55 | for(let item in obj) {
56 | if(obj.hasOwnProperty(item)) {
57 | len++
58 | }
59 | }
60 | return len
61 | }
62 |
63 | // returns array of object's key-value pairs
64 | function objectToArray(obj) {
65 | let arr = []
66 | for(let item in obj) {
67 | arr.push({
68 | name: item,
69 | content: obj[item]
70 | })
71 | }
72 | return arr
73 | }
74 |
75 | // This handy function allows to delay recursive actions. The taken arguments are explained below
76 | function delayFunction(
77 | callback, // function that contains whatever you want, takes an index and an optional array
78 | delay, // the time to wait between iterations in ms
79 | itemCount, // the maximum count
80 | arrayToPass=[], // optional array you want to process
81 | terminateAtIndex=itemCount, // optional index after which the callback is not delayed anymore, can be used when having many out-of-view items where the delay would stack up otherwise
82 | current=0 // current iteration index, only used by the function itself
83 | ) {
84 | if(itemCount-current > 0) {
85 | callback(current, arrayToPass)
86 | if(current >= terminateAtIndex) {
87 | debugLog('delay', 'terminated')
88 | delay = 0
89 | }
90 | setTimeout(() => {
91 | delayFunction(callback, delay, itemCount, arrayToPass, terminateAtIndex, current+1)
92 | }, delay)
93 | }
94 | }
95 |
96 | function loadImage(parent, src, loadingSrc) {
97 | let loading_img = document.createElement('img')
98 | loading_img.src = '../../assets/'+loadingSrc
99 |
100 | parent.appendChild(loading_img)
101 |
102 | let img = document.createElement('img')
103 | img.src = src
104 |
105 | img.onload = function() {
106 | setTimeout(() => {
107 | parent.removeChild(loading_img)
108 | parent.appendChild(img)
109 | }, 7*33.3) // some extra animation and framerate buffer
110 | }
111 | }
112 |
113 | /**
114 | * @param {object} options
115 | * @param options.parent dom element the image should be appended to
116 | * @param {'poster'} options.use in what type of element the image will be used
117 | * @param {'season'} options.type type the item belongs to
118 | * @param {number} options.itemId tvdb id of the item
119 | * @param {any} options.reference some reference we can use
120 | */
121 |
122 | async function requestAndLoadImage(options) {
123 | let loading_img = document.createElement('img')
124 | // the actual image, the placeholder gets updated to
125 | let img = document.createElement('img')
126 |
127 | switch(options.use) {
128 | case 'poster': {
129 | loading_img.src = '../../assets/loading_placeholder.gif'
130 | options.parent.appendChild(loading_img)
131 |
132 | switch(options.type) {
133 | case 'season': {
134 | img.src = await getSeasonPoster(options.itemId, options.reference)
135 |
136 | img.onload = function() {
137 | setTimeout(() => {
138 | options.parent.removeChild(loading_img)
139 | options.parent.appendChild(img)
140 | }, 3*33.3) // some extra animation and framerate buffer
141 | }
142 | break
143 | }
144 | }
145 | break
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/prototype/traktify.xd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/prototype/traktify.xd
--------------------------------------------------------------------------------