├── api ├── public │ ├── favicon.ico │ ├── robots.txt │ └── .htaccess ├── bootstrap │ └── cache │ │ └── .gitignore ├── storage │ ├── logs │ │ └── .gitignore │ ├── app │ │ ├── public │ │ │ └── .gitignore │ │ └── .gitignore │ └── framework │ │ ├── sessions │ │ └── .gitignore │ │ ├── testing │ │ └── .gitignore │ │ ├── views │ │ └── .gitignore │ │ ├── cache │ │ ├── data │ │ │ └── .gitignore │ │ └── .gitignore │ │ └── .gitignore ├── database │ ├── .gitignore │ ├── migrations │ │ ├── 2019_05_05_015450_create_genres_table.php │ │ ├── 2014_10_12_100000_create_password_resets_table.php │ │ ├── 2019_08_20_211514_add_is_online_column_to_users_table.php │ │ ├── 2019_05_04_101518_create_show_groups_table.php │ │ ├── 2019_05_05_015536_create_genre_show_table.php │ │ ├── 2019_07_02_023016_add_subtitle_url_column_to_show_videos_table.php │ │ ├── 2019_07_15_025106_add_is_dismissed_column__to_party_user_table.php │ │ ├── 2019_05_04_100644_create_party_user_table.php │ │ ├── 2019_05_04_100855_create_party_logs_table.php │ │ ├── 2019_05_05_042049_create_party_log_messages_table.php │ │ ├── 2019_05_05_042109_create_party_log_activities_table.php │ │ ├── 2019_06_20_142020_create_user_admin_record.php │ │ ├── 2019_05_04_100523_create_parties_table.php │ │ ├── 2014_10_12_000000_create_users_table.php │ │ ├── 2019_05_04_100535_create_party_invitations_table.php │ │ ├── 2019_05_04_101534_create_show_videos_table.php │ │ ├── 2019_05_04_101312_create_shows_table.php │ │ └── 2019_08_07_190850_update_synopsis_to_text_type_column_in_shows_table.php │ └── factories │ │ └── UserFactory.php ├── .gitattributes ├── .gitignore ├── tests │ ├── TestCase.php │ ├── Unit │ │ └── ExampleTest.php │ ├── Feature │ │ └── ExampleTest.php │ └── CreatesApplication.php ├── .styleci.yml ├── app │ ├── Genre.php │ ├── ShowVideo.php │ ├── Http │ │ ├── Middleware │ │ │ ├── EncryptCookies.php │ │ │ ├── CheckForMaintenanceMode.php │ │ │ ├── TrimStrings.php │ │ │ ├── TrustProxies.php │ │ │ ├── Authenticate.php │ │ │ ├── VerifyCsrfToken.php │ │ │ ├── RedirectIfAuthenticated.php │ │ │ ├── MemberOfParty.php │ │ │ └── RecipientOfInvitation.php │ │ ├── Controllers │ │ │ ├── Controller.php │ │ │ ├── Hooks │ │ │ │ ├── PusherHookEvent.php │ │ │ │ └── PusherHook.php │ │ │ ├── Auth │ │ │ │ ├── ForgotPasswordController.php │ │ │ │ ├── LoginController.php │ │ │ │ ├── ResetPasswordController.php │ │ │ │ └── VerificationController.php │ │ │ └── ShowsController.php │ │ └── Requests │ │ │ ├── GetPartyLogs.php │ │ │ ├── SendPartyMessage.php │ │ │ ├── UpdatePartyTime.php │ │ │ ├── SendInvitation.php │ │ │ ├── StoreParty.php │ │ │ ├── ChangePartyVideo.php │ │ │ ├── RegisterUser.php │ │ │ ├── UpdatePartyState.php │ │ │ └── DoInvitation.php │ ├── ShowGroup.php │ ├── Providers │ │ ├── AppServiceProvider.php │ │ ├── BroadcastServiceProvider.php │ │ ├── AuthServiceProvider.php │ │ └── EventServiceProvider.php │ ├── PartyMessage.php │ ├── PartyActivity.php │ ├── Rules │ │ └── RequestAccessCode.php │ ├── Console │ │ ├── Kernel.php │ │ └── Commands │ │ │ └── AppListUsers.php │ ├── Exceptions │ │ └── Handler.php │ ├── Events │ │ ├── PartyMessage.php │ │ ├── PartyInvitationCancelled.php │ │ ├── PartyInvitationDeclined.php │ │ ├── UserInvitationCancelled.php │ │ ├── PartyLogEvent.php │ │ ├── PartyState.php │ │ ├── PartyInvitationSent.php │ │ └── PartyVideoChanged.php │ ├── Show.php │ └── Party.php ├── .editorconfig ├── resources │ ├── sass │ │ ├── app.scss │ │ └── _variables.scss │ ├── lang │ │ └── en │ │ │ ├── pagination.php │ │ │ ├── auth.php │ │ │ └── passwords.php │ └── js │ │ ├── components │ │ └── ExampleComponent.vue │ │ └── app.js ├── routes │ ├── web.php │ ├── console.php │ └── channels.php ├── webpack.mix.js ├── server.php ├── .env.example ├── config │ ├── view.php │ ├── services.php │ ├── config.php │ └── hashing.php ├── package.json └── phpunit.xml ├── ui ├── src │ ├── screens │ │ ├── app │ │ │ ├── style.css │ │ │ ├── AppHeading │ │ │ │ ├── style.css │ │ │ │ └── index.tsx │ │ │ ├── AppHeadingSettings │ │ │ │ ├── style.css │ │ │ │ └── index.tsx │ │ │ ├── InvitationModal │ │ │ │ └── types.ts │ │ │ ├── constants.ts │ │ │ └── index.tsx │ │ ├── .dummy-route │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── app.watch │ │ │ ├── style.css │ │ │ ├── Context │ │ │ │ ├── index.ts │ │ │ │ ├── usePartyContext.ts │ │ │ │ └── Context.ts │ │ │ └── types.ts │ │ ├── app.settings-password │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── logout │ │ │ └── index.tsx │ │ ├── app.home │ │ │ ├── index.tsx │ │ │ └── GuestHome │ │ │ │ ├── style.css │ │ │ │ └── index.tsx │ │ ├── app.watch.home │ │ │ ├── PlayerTooltip │ │ │ │ ├── style.css │ │ │ │ └── index.tsx │ │ │ ├── PlayerSeeker │ │ │ │ └── style.css │ │ │ ├── ChatWidgetTip │ │ │ │ ├── style.css │ │ │ │ └── index.tsx │ │ │ ├── VolumeControl │ │ │ │ └── style.css │ │ │ ├── SubtitleSlot │ │ │ │ ├── utils.ts │ │ │ │ └── constants.ts │ │ │ ├── PlayerStateBufferedIndicator │ │ │ │ ├── index.tsx │ │ │ │ └── style.css │ │ │ ├── SeasonSelectionModal │ │ │ │ └── style.css │ │ │ └── KeyboardInfoModal │ │ │ │ └── style.css │ │ ├── login │ │ │ └── style.css │ │ ├── app.settings-profile │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── app.settings-faq │ │ │ └── style.css │ │ ├── register │ │ │ └── style.css │ │ └── app.download │ │ │ └── style.css │ ├── components │ │ ├── UiButtonLoader │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiGlobalLoader │ │ │ └── index.tsx │ │ ├── UiInputSlider │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiUserAvatar │ │ │ └── index.tsx │ │ ├── .dummy-component │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── RoutePermission │ │ │ ├── index.ts │ │ │ ├── GuestRoute.tsx │ │ │ └── PrivateRoute.tsx │ │ ├── UiLogo │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiPlainButton │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiFormSpacer │ │ │ └── index.tsx │ │ ├── ImageAspectRatio │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiAvatarGroup │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiAvatar │ │ │ ├── utils.ts │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiContainer │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── StandardImageAspectRatio │ │ │ └── index.tsx │ │ ├── WindowVhSetter │ │ │ └── index.ts │ │ ├── UiLoader │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiFormGroup │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiPresenceAvatar │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── UiSelect │ │ │ ├── index.tsx │ │ │ └── style.css │ │ ├── UiInput │ │ │ ├── index.tsx │ │ │ └── style.css │ │ ├── UiSpacer │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── CountdownTimer │ │ │ └── index.tsx │ │ ├── UiModal │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── RouterSwitch │ │ │ └── index.tsx │ │ ├── UiButton │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── KeyCastr │ │ │ └── style.css │ │ ├── UiAccordion │ │ │ ├── style.css │ │ │ └── index.tsx │ │ ├── GatewayDestWithFallback │ │ │ └── index.tsx │ │ ├── UiNavigation │ │ │ ├── style.css │ │ │ └── index.tsx │ │ └── Toast │ │ │ └── style.css │ ├── lib │ │ ├── uuid │ │ │ └── index.ts │ │ ├── history │ │ │ └── index.ts │ │ ├── axios │ │ │ ├── instance.ts │ │ │ ├── interceptor-oauth.ts │ │ │ ├── types.ts │ │ │ ├── interceptor-pusher.ts │ │ │ ├── interceptor-app-init.ts │ │ │ ├── interceptor-expired-tokens.ts │ │ │ ├── interceptor-error-handling.ts │ │ │ ├── index.ts │ │ │ ├── AxiosManager.tsx │ │ │ └── interceptor-toast-errors.ts │ │ ├── pusher │ │ │ ├── index.ts │ │ │ └── PusherManager.tsx │ │ └── srt2obj │ │ │ └── index.ts │ ├── assets │ │ ├── author.jpg │ │ ├── dummy-avatar.png │ │ ├── audio │ │ │ ├── chat-ener.ogg │ │ │ ├── chat-leave.ogg │ │ │ ├── chat-send.ogg │ │ │ ├── invitation.ogg │ │ │ ├── chat-inactive.ogg │ │ │ └── invitation-old.ogg │ │ ├── download-step-1.png │ │ ├── download-step-2.png │ │ ├── download-step-3.png │ │ ├── download-step-4.png │ │ ├── show-thumbnail-218x146.jpg │ │ └── loader.svg │ ├── utils │ │ ├── last.ts │ │ ├── clamp.ts │ │ ├── api │ │ │ └── getValidationMessage.ts │ │ ├── date │ │ │ ├── parseStandardTime.ts │ │ │ ├── getStandardFormattedDateTime.ts │ │ │ ├── fromReadableTime.ts │ │ │ ├── getFormattedDuration.ts │ │ │ ├── getFormattedDurationWithoutSeconds.ts │ │ │ ├── getRemainingTime.ts │ │ │ ├── getFormattedRemainingTime.ts │ │ │ └── toReadableTime.ts │ │ ├── shows │ │ │ ├── getVideoPreviewImage.ts │ │ │ ├── getVideoDetails.ts │ │ │ └── getAirDetails.ts │ │ ├── random.ts │ │ ├── dom │ │ │ └── isFocusedToInput.ts │ │ ├── goify.ts │ │ ├── toSearchObject.ts │ │ └── toSearchIndexObject.ts │ ├── hooks │ │ ├── useIsPWA.ts │ │ ├── useMediaMode.ts │ │ ├── useRouterBlock.ts │ │ ├── useCollectionState.ts │ │ ├── useNow.ts │ │ ├── usePropRef.ts │ │ ├── useUpdateDebounce.ts │ │ ├── useCountdownTimer.ts │ │ ├── usePusher.ts │ │ ├── useBufferState.ts │ │ ├── useFullscreen.ts │ │ ├── useFormState.ts │ │ └── useRequest.ts │ ├── manifest.webmanifest │ ├── custom.d.ts │ ├── index.html │ ├── types │ │ └── react-input-slider │ │ │ └── index.d.ts │ └── config.ts ├── netlify │ └── _redirects ├── .gitignore ├── .env.example ├── .prettierrc └── tsconfig.json ├── .DS_Store ├── preview.png └── misc ├── generate-text-files.js ├── rename-series.js ├── extract-subs-movie.js ├── extract-subs-movie-batch.js ├── extract-subs-series.js └── utils.js /api/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/screens/app/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/screens/.dummy-route/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/screens/app.watch/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/UiButtonLoader/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/UiGlobalLoader/index.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/UiInputSlider/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/UiUserAvatar/index.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/screens/app/AppHeading/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/netlify/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /ui/src/components/.dummy-component/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/screens/app.settings-password/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/screens/app/AppHeadingSettings/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /api/storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | *.sqlite-journal 3 | -------------------------------------------------------------------------------- /api/storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /api/storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /api/storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /ui/src/lib/uuid/index.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | export default v4 -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/.DS_Store -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/preview.png -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Thumbs.db 3 | .DS_Store 4 | dist 5 | .cache 6 | .env -------------------------------------------------------------------------------- /ui/src/assets/author.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/author.jpg -------------------------------------------------------------------------------- /ui/src/assets/dummy-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/dummy-avatar.png -------------------------------------------------------------------------------- /ui/src/lib/history/index.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | export default createBrowserHistory() -------------------------------------------------------------------------------- /ui/src/assets/audio/chat-ener.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/audio/chat-ener.ogg -------------------------------------------------------------------------------- /ui/src/assets/audio/chat-leave.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/audio/chat-leave.ogg -------------------------------------------------------------------------------- /ui/src/assets/audio/chat-send.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/audio/chat-send.ogg -------------------------------------------------------------------------------- /ui/src/assets/audio/invitation.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/audio/invitation.ogg -------------------------------------------------------------------------------- /ui/src/assets/download-step-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/download-step-1.png -------------------------------------------------------------------------------- /ui/src/assets/download-step-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/download-step-2.png -------------------------------------------------------------------------------- /ui/src/assets/download-step-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/download-step-3.png -------------------------------------------------------------------------------- /ui/src/assets/download-step-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/download-step-4.png -------------------------------------------------------------------------------- /ui/src/assets/audio/chat-inactive.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/audio/chat-inactive.ogg -------------------------------------------------------------------------------- /ui/src/utils/last.ts: -------------------------------------------------------------------------------- 1 | function last(array: T[]): T { 2 | return array[array.length - 1] 3 | } 4 | 5 | export default last -------------------------------------------------------------------------------- /ui/src/assets/audio/invitation-old.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/audio/invitation-old.ogg -------------------------------------------------------------------------------- /ui/src/assets/show-thumbnail-218x146.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/careflix/HEAD/ui/src/assets/show-thumbnail-218x146.jpg -------------------------------------------------------------------------------- /api/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.scss linguist-vendored 4 | *.js linguist-vendored 5 | CHANGELOG.md export-ignore 6 | -------------------------------------------------------------------------------- /ui/src/screens/app/InvitationModal/types.ts: -------------------------------------------------------------------------------- 1 | export interface ContextType { 2 | isOpen: boolean 3 | open: () => void 4 | close: () => void 5 | } -------------------------------------------------------------------------------- /ui/.env.example: -------------------------------------------------------------------------------- 1 | API_BASE_URL= 2 | API_CLIENT_ID= 3 | API_CLIENT_SECRET= 4 | 5 | PUSHER_APP_ID= 6 | PUSHER_KEY= 7 | PUSHER_SECRET= 8 | PUSHER_CLUSTER= -------------------------------------------------------------------------------- /ui/src/components/RoutePermission/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GuestRoute } from './GuestRoute' 2 | export { default as PrivateRoute } from './PrivateRoute' -------------------------------------------------------------------------------- /ui/src/screens/app.watch/Context/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Context } from './Context' 2 | export { default as usePartyContext } from './usePartyContext' -------------------------------------------------------------------------------- /ui/src/utils/clamp.ts: -------------------------------------------------------------------------------- 1 | export default function clamp(number: number, min: number, max: number): number { 2 | return Math.max(Math.min(number, max), min) 3 | } -------------------------------------------------------------------------------- /ui/src/screens/app/constants.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | gateway: { 3 | title: 'app-heading-title', 4 | backUrl: 'app-heading-back-url' 5 | } 6 | } -------------------------------------------------------------------------------- /api/storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | routes.php 3 | schedule-* 4 | compiled.php 5 | services.json 6 | events.scanned.php 7 | routes.scanned.php 8 | down 9 | -------------------------------------------------------------------------------- /ui/src/components/UiLogo/style.css: -------------------------------------------------------------------------------- 1 | .ui-logo { 2 | font-family: var(--font-family-subheading); 3 | font-size: var(--font-size-h4); 4 | color: var(--color-primary); 5 | } -------------------------------------------------------------------------------- /ui/src/utils/api/getValidationMessage.ts: -------------------------------------------------------------------------------- 1 | export default function getValidationMessage(errors: AppValidationBag, key: string) { 2 | return errors[key] ? errors[key][0] : '' 3 | } -------------------------------------------------------------------------------- /ui/src/lib/axios/instance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import config from '~/config' 3 | 4 | const instance = axios.create({ 5 | baseURL: config.api.baseUrl 6 | }) 7 | 8 | export default instance -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/hot 3 | /public/storage 4 | /storage/*.key 5 | /vendor 6 | .env 7 | .phpunit.result.cache 8 | Homestead.json 9 | Homestead.yaml 10 | npm-debug.log 11 | yarn-error.log 12 | -------------------------------------------------------------------------------- /ui/src/components/UiPlainButton/style.css: -------------------------------------------------------------------------------- 1 | .ui-plain-button { 2 | display: inline-block; 3 | padding: 0; 4 | color: inherit; 5 | background: transparent; 6 | border: 0; 7 | outline: 0; 8 | cursor: pointer; 9 | } -------------------------------------------------------------------------------- /api/tests/TestCase.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Show::class); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/UiLogo/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | 4 | function UiLogo() { 5 | return ( 6 |
7 | Care.tv 8 |
9 | ) 10 | } 11 | 12 | export default UiLogo -------------------------------------------------------------------------------- /ui/src/components/UiFormSpacer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import UiSpacer from '~/components/UiSpacer' 3 | 4 | function UiFormSpacer() { 5 | return ( 6 | 7 | ) 8 | } 9 | 10 | export default UiFormSpacer -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": true, 10 | "fluid": false 11 | } -------------------------------------------------------------------------------- /ui/src/screens/app.watch/Context/usePartyContext.ts: -------------------------------------------------------------------------------- 1 | import Context from './Context' 2 | import { useContext } from 'react' 3 | import { ContextType } from '../types' 4 | 5 | export default function usePartyContext() { 6 | return useContext(Context) 7 | } -------------------------------------------------------------------------------- /ui/src/utils/date/parseStandardTime.ts: -------------------------------------------------------------------------------- 1 | // We just want to make it an alias for documentation purposes. 2 | // Turns out, new Date() is an issue for iOS Chrome. 3 | // @see https://stackoverflow.com/a/7610920/2698227 4 | export { parse as default } from 'date-fns' -------------------------------------------------------------------------------- /ui/src/hooks/useIsPWA.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | function useIsPWA(): boolean { 4 | return useMemo(() => { 5 | return window.matchMedia('(display-mode: standalone)').matches 6 | }, []) 7 | } 8 | 9 | export { 10 | useIsPWA, 11 | useIsPWA as default 12 | } -------------------------------------------------------------------------------- /ui/src/utils/shows/getVideoPreviewImage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * If the video doesn't have a preview image, we'll fallback to the show's preview image. 3 | */ 4 | export default function getVideoPreviewImage(party: AppParty): string { 5 | return party.video.preview_image || party.video.show.preview_image 6 | } -------------------------------------------------------------------------------- /api/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /ui/src/components/ImageAspectRatio/style.css: -------------------------------------------------------------------------------- 1 | .c-image-aspect-ratio { 2 | position: relative; 3 | width: 100%; 4 | } 5 | 6 | .c-image-aspect-ratio > img { 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | right: 0; 11 | bottom: 0; 12 | height: 100%; 13 | width: 100%; 14 | } -------------------------------------------------------------------------------- /ui/src/components/UiAvatarGroup/style.css: -------------------------------------------------------------------------------- 1 | .ui-avatar-group { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .ui-avatar-group > .avatar:not(:last-child) { 7 | margin-right: 2px; 8 | } 9 | 10 | .ui-avatar-group > .more { 11 | flex-shrink: 0; 12 | margin-left: 8px; 13 | } -------------------------------------------------------------------------------- /ui/src/screens/logout/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useAuth } from '~/contexts/Auth' 3 | 4 | function Logout() { 5 | const auth = useAuth() 6 | 7 | useEffect(() => { 8 | auth.logout() 9 | }, []) 10 | 11 | return null 12 | } 13 | 14 | export default Logout -------------------------------------------------------------------------------- /ui/src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Care.tv", 3 | "short_name": "Care.tv", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "background_color": "#10171E", 7 | "description": "Care.tv makes it really easy to enjoy movies or watch TV shows with the people you care about, no matter how far!" 8 | } -------------------------------------------------------------------------------- /ui/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @source https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random 3 | */ 4 | export default function random(min: number, max: number) { 5 | min = Math.ceil(min); 6 | max = Math.floor(max); 7 | return Math.floor(Math.random() * (max - min)) + min 8 | } -------------------------------------------------------------------------------- /ui/src/hooks/useMediaMode.ts: -------------------------------------------------------------------------------- 1 | import useWindowSize from 'react-use/lib/useWindowSize' 2 | 3 | function useMediaMode(): 'desktop' | 'mobile' { 4 | const { width } = useWindowSize(); 5 | return width >= 640 ? 'desktop' : 'mobile' 6 | } 7 | 8 | export { 9 | useMediaMode, 10 | useMediaMode as default 11 | } -------------------------------------------------------------------------------- /api/resources/sass/app.scss: -------------------------------------------------------------------------------- 1 | // Fonts 2 | @import url('https://fonts.googleapis.com/css?family=Nunito'); 3 | 4 | // Variables 5 | @import 'variables'; 6 | 7 | // Bootstrap 8 | @import '~bootstrap/scss/bootstrap'; 9 | 10 | .navbar-laravel { 11 | background-color: #fff; 12 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/screens/app.home/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import GuestHome from './GuestHome' 3 | import AuthHome from './AuthHome' 4 | import { useAuth } from '~/contexts/Auth' 5 | 6 | function AppHome() { 7 | const auth = useAuth() 8 | return auth.isGuest ? : 9 | } 10 | 11 | export default AppHome 12 | -------------------------------------------------------------------------------- /ui/src/components/.dummy-component/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | 4 | interface Props { 5 | children?: React.ReactNode 6 | } 7 | 8 | function UiDummy(props: Props) { 9 | return ( 10 |
11 | {props.children} 12 |
13 | ) 14 | } 15 | 16 | export default UiDummy -------------------------------------------------------------------------------- /ui/src/screens/app.watch/Context/Context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ContextType } from '../types' 3 | 4 | export default React.createContext({ 5 | party: null, 6 | isLoading: false, 7 | onCancel: () => {}, 8 | onInvite: () => {}, 9 | onAccept: () => {}, 10 | onDecline: () => {}, 11 | onChangeVideo: () => {} 12 | }) -------------------------------------------------------------------------------- /api/app/ShowVideo.php: -------------------------------------------------------------------------------- 1 | belongsTo(Show::class); 11 | } 12 | 13 | public function group() { 14 | return $this->belongsTo(ShowGroup::class, 'show_group_id'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ui/src/utils/date/getStandardFormattedDateTime.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | 3 | /** 4 | * Gives you a mysql standard formatted datetime 5 | * e.g., 2018-08-08 23:00:00 6 | */ 7 | function getStandardFormattedDateTime(date: Date = new Date()) { 8 | return format(date, 'YYYY-MM-DD HH:mm:ss') 9 | } 10 | 11 | export default getStandardFormattedDateTime -------------------------------------------------------------------------------- /ui/src/screens/.dummy-route/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import './style' 3 | 4 | /** 5 | * Use this to create a route instead of typing everything down 6 | */ 7 | function DummyRoute(props: ReactComponentWrapper) { 8 | return ( 9 | 10 | {props.children} 11 | 12 | ) 13 | } 14 | 15 | export default DummyRoute -------------------------------------------------------------------------------- /ui/src/hooks/useRouterBlock.ts: -------------------------------------------------------------------------------- 1 | import history from '~/lib/history' 2 | import { useEffect } from 'react'; 3 | 4 | type Callback = (location, action) => string | null 5 | 6 | function useRouterBlock(cb: Callback, args: any[]) { 7 | useEffect(() => { 8 | return history.block(cb) 9 | }, args) 10 | } 11 | 12 | export { 13 | useRouterBlock, 14 | useRouterBlock as default 15 | } -------------------------------------------------------------------------------- /api/app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/components/UiAvatar/utils.ts: -------------------------------------------------------------------------------- 1 | import ColorHash from 'color-hash' 2 | 3 | const hash = new ColorHash() 4 | 5 | export function getBgFromInitials(initials: string): string { 6 | return hash.hex(initials) 7 | } 8 | 9 | export function getInitials(name: string) { 10 | const [f, l]: string[] = name.split(' ') 11 | return [f.charAt(0).toUpperCase(), (l || '').charAt(0).toUpperCase()].join('') 12 | } -------------------------------------------------------------------------------- /ui/src/utils/date/fromReadableTime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the number of seconds from readable time (01:03:20,200 -> 3800) 3 | */ 4 | function fromReadableTime(time: string): number { 5 | const [hour, minute, seconds] = time.split(',')[0].split(':') 6 | 7 | return ( 8 | (Number(hour) * 60 * 60) + 9 | (Number(minute) * 60) + 10 | Number(seconds) 11 | ) 12 | } 13 | 14 | export default fromReadableTime -------------------------------------------------------------------------------- /ui/src/components/UiContainer/style.css: -------------------------------------------------------------------------------- 1 | .ui-container { 2 | padding-left: 16px; 3 | padding-right: 16px; 4 | margin: 0 auto; 5 | width: 100%; 6 | } 7 | 8 | .ui-container.is-xl { 9 | max-width: 1200px; 10 | } 11 | 12 | .ui-container.is-lg { 13 | max-width: 992px; 14 | } 15 | 16 | .ui-container.is-md { 17 | max-width: 768px; 18 | } 19 | 20 | .ui-container.is-sm { 21 | max-width: 480px; 22 | } -------------------------------------------------------------------------------- /api/resources/sass/_variables.scss: -------------------------------------------------------------------------------- 1 | // Body 2 | $body-bg: #f8fafc; 3 | 4 | // Typography 5 | $font-family-sans-serif: 'Nunito', sans-serif; 6 | $font-size-base: 0.9rem; 7 | $line-height-base: 1.6; 8 | 9 | // Colors 10 | $blue: #3490dc; 11 | $indigo: #6574cd; 12 | $purple: #9561e2; 13 | $pink: #f66d9b; 14 | $red: #e3342f; 15 | $orange: #f6993f; 16 | $yellow: #ffed4a; 17 | $green: #38c172; 18 | $teal: #4dc0b5; 19 | $cyan: #6cb2eb; 20 | -------------------------------------------------------------------------------- /ui/src/components/StandardImageAspectRatio/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ImageAspectRatio, { Props as ImageAspectRatioProps } from '~/components/ImageAspectRatio' 3 | 4 | type Props = Omit 5 | 6 | function StandardImageAspectRatio(props: Props) { 7 | return ( 8 | 9 | ) 10 | } 11 | 12 | export default StandardImageAspectRatio -------------------------------------------------------------------------------- /ui/src/lib/axios/interceptor-oauth.ts: -------------------------------------------------------------------------------- 1 | import instance from './instance' 2 | import { AuthContext } from '~/contexts/Auth' 3 | 4 | export default { 5 | setup: (auth: AuthContext): number => { 6 | return instance.interceptors.request.use((config) => { 7 | if (auth.token != null) { 8 | config.headers['Authorization'] = `Bearer ${auth.token}`; 9 | } 10 | 11 | return config; 12 | }) 13 | } 14 | } -------------------------------------------------------------------------------- /api/app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | extends BaseAxiosError { 10 | response: AxiosResponse 11 | config: AxiosRequestConfig 12 | } -------------------------------------------------------------------------------- /api/app/Http/Middleware/CheckForMaintenanceMode.php: -------------------------------------------------------------------------------- 1 | get('/'); 18 | 19 | $response->assertStatus(200); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/custom.d.ts: -------------------------------------------------------------------------------- 1 | // @source https://stackoverflow.com/a/45887328 2 | declare module "*.svg" { 3 | const content: any; 4 | export default content; 5 | } 6 | 7 | declare module "*.ogg" { 8 | const content: any; 9 | export default content; 10 | } 11 | 12 | declare module "*.jpg" { 13 | const content: any; 14 | export default content; 15 | } 16 | 17 | declare module "*.png" { 18 | const content: any; 19 | export default content; 20 | } -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/PlayerTooltip/style.css: -------------------------------------------------------------------------------- 1 | .app-watch-player-tooltip-popover-container { 2 | z-index: var(--zindex-player-tooltip); 3 | } 4 | 5 | .app-watch-player-tooltip-popover { 6 | display: none; 7 | padding: 16px; 8 | font-size: 16px; 9 | background: var(--color-black-2); 10 | border-radius: var(--border-radius); 11 | } 12 | 13 | 14 | @media (min-width: 992px) { 15 | .app-watch-player-tooltip-popover { 16 | display: block; 17 | } 18 | } -------------------------------------------------------------------------------- /ui/src/hooks/useCollectionState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | function useCollectionState() { 4 | return useState>(() => ({ 5 | data: [], 6 | current_page: 1, 7 | last_page: 1, 8 | from: 1, 9 | to: 1, 10 | path: '', 11 | first_page_url: '', 12 | per_page: 1, 13 | prev_page_url: null, 14 | next_page_url: null 15 | })) 16 | } 17 | 18 | export { useCollectionState, useCollectionState as default } 19 | -------------------------------------------------------------------------------- /ui/src/lib/axios/interceptor-pusher.ts: -------------------------------------------------------------------------------- 1 | import instance from './instance' 2 | import pusher from '~/lib/pusher' 3 | import { AuthContext } from '~/contexts/Auth' 4 | 5 | export default { 6 | setup: (auth: AuthContext): number => { 7 | return instance.interceptors.request.use((config) => { 8 | if (auth.token != null) { 9 | config.headers['X-Socket-ID'] = pusher().connection.socket_id; 10 | } 11 | 12 | return config; 13 | }) 14 | } 15 | } -------------------------------------------------------------------------------- /ui/src/components/WindowVhSetter/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import useWindowSize from 'react-use/lib/useWindowSize' 3 | 4 | /** 5 | * @NOTE Don't mount this twice; only on the root node. 6 | */ 7 | function WindowVhSetter() { 8 | const { height } = useWindowSize() 9 | 10 | useEffect(() => { 11 | document.body.style.setProperty('--window-vh', `${height}px`) 12 | }, [height]) 13 | 14 | return null 15 | } 16 | 17 | export default WindowVhSetter 18 | -------------------------------------------------------------------------------- /api/tests/CreatesApplication.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 19 | 20 | return $app; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/ImageAspectRatio/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | 4 | export type Props = React.ImgHTMLAttributes & { 5 | ratio: number 6 | } 7 | 8 | function ImageAspectRatio(props: Props) { 9 | const { ratio, ...imgProps } = props 10 | 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | 18 | export default ImageAspectRatio -------------------------------------------------------------------------------- /ui/src/hooks/useNow.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import useInterval from '@use-it/interval' 3 | 4 | interface Props { 5 | interval: number 6 | } 7 | 8 | /** 9 | * Provides you an updated current date instance 10 | */ 11 | function useNow(props: Props) { 12 | const [now, setNow] = useState(new Date()) 13 | 14 | useInterval(() => { 15 | setNow(new Date()) 16 | }, props.interval) 17 | 18 | return now 19 | } 20 | 21 | export { 22 | useNow, 23 | useNow as default 24 | } -------------------------------------------------------------------------------- /ui/src/utils/shows/getVideoDetails.ts: -------------------------------------------------------------------------------- 1 | import parseStandardTime from '~/utils/date/parseStandardTime' 2 | 3 | /** 4 | * For series, display season and episode index. For movies, display air date (2018) 5 | * 6 | * @NOTE This assumes that video has `group`. 7 | */ 8 | export default function getVideoDetails(video: AppShowVideo) { 9 | if (video.show.title_type === 'series') { 10 | return `${video.group.title}: ${video.title}` 11 | } 12 | 13 | return parseStandardTime(video.show.air_start).getFullYear() 14 | } -------------------------------------------------------------------------------- /ui/src/lib/axios/interceptor-app-init.ts: -------------------------------------------------------------------------------- 1 | import instance from './instance' 2 | import { AxiosRequestConfig as BaseAxiosRequestConfig } from 'axios' 3 | import { AxiosRequestConfig } from './types' 4 | 5 | export default { 6 | setup: (): number => { 7 | return instance.interceptors.request.use((config: BaseAxiosRequestConfig) => { 8 | if (!('app' in config)) { 9 | ;(config).app = {} 10 | } 11 | 12 | return config as AxiosRequestConfig 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/utils/dom/isFocusedToInput.ts: -------------------------------------------------------------------------------- 1 | const inputs = [ 2 | 'input', 3 | 'textarea', 4 | 'select' 5 | ] 6 | 7 | /** 8 | * Checks if the document is focused to any kind of input element (input, textarea, select) 9 | */ 10 | export default function isFocusedToInput(): boolean { 11 | if (!document.activeElement) { 12 | return false 13 | } 14 | 15 | for (let el of inputs) { 16 | if (document.activeElement.tagName.toLowerCase() === el) { 17 | return true 18 | } 19 | } 20 | 21 | return false 22 | } -------------------------------------------------------------------------------- /ui/src/utils/goify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * promise().then().catch() -> [err, data] = promise() 3 | * @source https://twitter.com/DavidWells/status/1119729914876284928 4 | */ 5 | export default async function(promise: Promise): Promise { 6 | let data: any = null 7 | try { 8 | data = await promise 9 | } catch(e) { 10 | if (process.env.NODE_ENV !== 'production') { 11 | console.error(e) 12 | } 13 | return [e] 14 | } 15 | return data instanceof Error ? [data] : [null, data] 16 | } -------------------------------------------------------------------------------- /api/routes/web.php: -------------------------------------------------------------------------------- 1 | belongsTo(Show::class); 20 | } 21 | 22 | public function videos() { 23 | return $this->hasMany(ShowVideo::class); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/components/UiPlainButton/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | 5 | type Props = React.ButtonHTMLAttributes 6 | 7 | function UiPlainButton({ className, ...props }: Props, ref: React.MutableRefObject) { 8 | return ( 9 | 12 | ) 13 | } 14 | 15 | export default React.forwardRef(UiPlainButton) 16 | -------------------------------------------------------------------------------- /ui/src/lib/axios/interceptor-expired-tokens.ts: -------------------------------------------------------------------------------- 1 | import instance from './instance' 2 | import { AuthContext } from '~/contexts/Auth' 3 | import { AxiosError } from 'axios'; 4 | 5 | export default { 6 | setup: (auth: AuthContext): number => { 7 | return instance.interceptors.response.use(null, (err: AxiosError) => { 8 | if (err.response && !err.config.url.includes('/oauth/token') && err.response.status === 401) { 9 | auth.logout() 10 | } 11 | 12 | return Promise.reject(err) 13 | }) 14 | } 15 | } -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/PlayerSeeker/style.css: -------------------------------------------------------------------------------- 1 | .app-watch-player-seeker { 2 | position: relative; 3 | } 4 | 5 | @media (min-width: 992px) { 6 | .app-watch-player-seeker { 7 | padding-top: 16px; 8 | padding-bottom: 16px; 9 | } 10 | } 11 | 12 | .app-watch-player-seeker > .time { 13 | position: absolute; 14 | top: -32px; 15 | left: 0; 16 | padding: 8px; 17 | background: var(--color-black-4); 18 | width: 80px; 19 | text-align: center; 20 | border-radius: var(--border-radius); 21 | pointer-events: none; 22 | } -------------------------------------------------------------------------------- /ui/src/lib/axios/interceptor-error-handling.ts: -------------------------------------------------------------------------------- 1 | // import { AxiosError } from 'axios' 2 | // import instance from './instance' 3 | // import { ErrorContainer } from '~/containers' 4 | 5 | // instance.interceptors.response.use(null, (error: AxiosError) => { 6 | // if (error.config.method === 'get' && !error.config.url.includes('me') && error.response) { 7 | // const { status } = error.response 8 | // ErrorContainer.set(status === 404 || status === 403 ? 404 : 500) 9 | // } 10 | 11 | // return Promise.reject(error); 12 | // }) -------------------------------------------------------------------------------- /api/app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | img { 6 | width: 16px; 7 | animation-name: ui-loader-animation; 8 | animation-duration: 1s; 9 | animation-timing-function: ease; 10 | animation-iteration-count: infinite; 11 | transform-origin: 100% 50% 0; 12 | } 13 | 14 | .ui-loader.is-large > img { 15 | width: 32px; 16 | } 17 | 18 | @keyframes ui-loader-animation { 19 | 0% { 20 | transform: rotate(0deg); 21 | } 22 | 100% { 23 | transform: rotate(360deg); 24 | } 25 | } -------------------------------------------------------------------------------- /api/app/Providers/BroadcastServiceProvider.php: -------------------------------------------------------------------------------- 1 | 'auth:api' 19 | ]); 20 | 21 | require base_path('routes/channels.php'); 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /ui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Care.tv 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /api/app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 18 | return route('login'); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/components/RoutePermission/GuestRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useAuth } from '~/contexts/Auth' 3 | import { Route, Redirect } from 'react-router-dom' 4 | import { RouteProps } from 'react-router' 5 | 6 | function GuestRoute({ component: Component, render, ...rest }: RouteProps) { 7 | const auth = useAuth() 8 | 9 | return ( 10 | (auth.isGuest ? Component ? : render(props) : )} 13 | /> 14 | ) 15 | } 16 | 17 | export default GuestRoute 18 | -------------------------------------------------------------------------------- /ui/src/components/UiFormGroup/style.css: -------------------------------------------------------------------------------- 1 | .ui-form-group { 2 | 3 | } 4 | 5 | .ui-form-group > .label { 6 | display: block; 7 | margin-bottom: 4px; 8 | font-weight: 600; 9 | color: var(--color-black-5); 10 | line-height: 1; 11 | } 12 | 13 | .ui-form-group > .hint { 14 | display: block; 15 | margin-top: 4px; 16 | line-height: 1.5; 17 | font-size: 14px; 18 | color: var(--color-black-5); 19 | } 20 | 21 | .ui-form-group > .hint > .icon { 22 | display: inline-block; 23 | margin-right: 8px; 24 | } 25 | 26 | .ui-form-group a { 27 | color: #1da1f2; 28 | } -------------------------------------------------------------------------------- /ui/src/components/UiLoader/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | import asset_loader from '~/assets/loader.svg' 5 | 6 | interface Props { 7 | size?: 'medium' | 'large' 8 | } 9 | 10 | /** 11 | * @source https://projects.lukehaas.me/css-loaders/ 12 | */ 13 | function UiLoader(props: Props) { 14 | return ( 15 |
16 | Loading... 17 |
18 | ) 19 | } 20 | 21 | export default UiLoader 22 | -------------------------------------------------------------------------------- /ui/src/utils/date/getFormattedDuration.ts: -------------------------------------------------------------------------------- 1 | import getRemainingTime from './getRemainingTime' 2 | 3 | /** 4 | * Unlike `getFormattedRemainingTime`, this simply asks for the duration. 5 | */ 6 | export default function getFormattedDuration(seconds: number): string { 7 | const remaining = getRemainingTime(seconds) 8 | 9 | if (remaining.hours >= 1) { 10 | return `${remaining.hours}h ${remaining.minutes}m ${remaining.seconds}s` 11 | } 12 | 13 | if (remaining.minutes >= 1) { 14 | return `${remaining.minutes}m ${remaining.seconds}s` 15 | } 16 | 17 | return `${remaining.seconds}s` 18 | } -------------------------------------------------------------------------------- /api/app/Http/Middleware/VerifyCsrfToken.php: -------------------------------------------------------------------------------- 1 | .icon { 11 | flex-shrink: 0; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | padding-top: 4px; 16 | margin-left: 8px; 17 | margin-right: 16px; 18 | font-size: 28px; 19 | align-self: flex-start; 20 | } 21 | 22 | .app-watch-chat-widget-tip > .text { 23 | line-height: 1.5; 24 | } -------------------------------------------------------------------------------- /api/app/Http/Controllers/Hooks/PusherHookEvent.php: -------------------------------------------------------------------------------- 1 | channel = str_replace('presence-', '', $event['channel']); 28 | $this->name = $event['name']; 29 | $this->user = User::find($event['user_id']); 30 | } 31 | } -------------------------------------------------------------------------------- /ui/src/components/UiContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | 5 | interface Props { 6 | children?: React.ReactNode 7 | size?: 'sm' | 'md' | 'lg' | 'xl' 8 | } 9 | 10 | function UiContainer(props: Props) { 11 | return ( 12 |
18 | {props.children} 19 |
20 | ) 21 | } 22 | 23 | export default UiContainer -------------------------------------------------------------------------------- /ui/src/screens/login/style.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 640px) { 2 | .login-container { 3 | padding-top: 64px; 4 | padding-bottom: 64px; 5 | } 6 | } 7 | 8 | .login-title { 9 | text-align: center; 10 | margin-bottom: 40px; 11 | } 12 | 13 | .login-image { 14 | text-align: center; 15 | margin-bottom: 16px; 16 | } 17 | 18 | .login-image > img { 19 | max-width: 162px; 20 | } 21 | 22 | .login-action { 23 | margin-bottom: 16px; 24 | } 25 | 26 | .login-byline { 27 | text-align: center; 28 | } 29 | 30 | .login-byline a { 31 | color: var(--color-primary); 32 | text-decoration: none; 33 | } -------------------------------------------------------------------------------- /ui/src/utils/shows/getAirDetails.ts: -------------------------------------------------------------------------------- 1 | import parseStandardTime from '~/utils/date/parseStandardTime' 2 | 3 | /** 4 | * For series, display range (2018-2019). For movies, display air date (2018) 5 | */ 6 | export default function getAirDetails(show: AppShow): string { 7 | const start: number = parseStandardTime(show.air_start).getFullYear() 8 | 9 | if (show.title_type === 'series') { 10 | if (show.air_end == null) { 11 | return `${start}-?` 12 | } 13 | const end: number = parseStandardTime(show.air_end).getFullYear() 14 | return `${start}-${end}` 15 | } 16 | 17 | return String(start) 18 | } -------------------------------------------------------------------------------- /ui/src/components/RoutePermission/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useAuth } from '~/contexts/Auth' 3 | import { Route, Redirect } from 'react-router-dom' 4 | import { RouteProps } from 'react-router' 5 | 6 | function PrivateRoute({ component: Component, render, ...rest }: RouteProps) { 7 | const auth = useAuth() 8 | 9 | return ( 10 | 13 | auth.isAuthenticated ? Component ? : render(props) : 14 | } 15 | /> 16 | ) 17 | } 18 | 19 | export default PrivateRoute 20 | -------------------------------------------------------------------------------- /ui/src/screens/app.settings-profile/style.css: -------------------------------------------------------------------------------- 1 | .settings-profile-avatar { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | margin-bottom: 32px; 6 | } 7 | 8 | .settings-profile-avatar > .avatar { 9 | position: relative; 10 | } 11 | 12 | .settings-profile-avatar > .avatar > .action { 13 | position: absolute; 14 | top: -8px; 15 | right: -8px; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | height: 32px; 20 | width: 32px; 21 | background: var(--color-black-5); 22 | border: 2px solid var(--color-black-2); 23 | border-radius: 50%; 24 | } -------------------------------------------------------------------------------- /api/resources/lang/en/pagination.php: -------------------------------------------------------------------------------- 1 | '« Previous', 17 | 'next' => 'Next »', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /api/routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 18 | })->describe('Display an inspiring quote'); 19 | -------------------------------------------------------------------------------- /ui/src/utils/date/getFormattedDurationWithoutSeconds.ts: -------------------------------------------------------------------------------- 1 | import getFormattedDuration from './getFormattedDuration' 2 | 3 | // In a string like 2h 35m 31s, we'll match "31s". 4 | const SECONDS_REGEX = /[0-9]{1,2}s$/ 5 | 6 | /** 7 | * Unlike `getFormattedRemainingTime`, this simply asks for the duration. 8 | * 9 | * @NOTE BEWARE! This function assumes that the duration will be over 60 seconds. 10 | * Otherwise we'll get a blank string! 11 | */ 12 | export default function getFormattedDurationWithoutSeconds(seconds: number): string { 13 | return getFormattedDuration(seconds) 14 | .replace(SECONDS_REGEX, '') 15 | .trimRight() 16 | } 17 | -------------------------------------------------------------------------------- /api/server.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | 10 | $uri = urldecode( 11 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 12 | ); 13 | 14 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 15 | // built-in PHP web server. This provides a convenient way to test a Laravel 16 | // application without having installed a "real" web server software here. 17 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 18 | return false; 19 | } 20 | 21 | require_once __DIR__.'/public/index.php'; 22 | -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/VolumeControl/style.css: -------------------------------------------------------------------------------- 1 | .app-watch-volume-control-action { 2 | display: flex; 3 | align-items: center; 4 | padding: 16px 0; 5 | } 6 | 7 | .app-watch-volume-control-action > .slider { 8 | width: 0; 9 | opacity: 0; 10 | transition: 200ms width ease, 400ms opacity ease; 11 | transition-delay: 0ms, 200ms; 12 | } 13 | 14 | .app-watch-volume-control-action:hover > .icon { 15 | margin-right: 16px; 16 | } 17 | 18 | .app-watch-volume-control-action:hover > .slider { 19 | width: 80px; 20 | opacity: 1; 21 | transition-delay: 0ms, 0ms; 22 | } 23 | 24 | .app-watch-volume-control-action.is-muted > .icon { 25 | color: red; 26 | } -------------------------------------------------------------------------------- /ui/src/utils/date/getRemainingTime.ts: -------------------------------------------------------------------------------- 1 | interface RemainingTimeValue { 2 | hours: number 3 | minutes: number 4 | seconds: number 5 | } 6 | 7 | /** 8 | * Get remaining time from seconds 9 | * 10 | * Used by `toReadableTime` and `distanceInWordsAbbreivated` 11 | * 12 | * @TODO Rename to `getDuration`, maybe? 13 | */ 14 | export default function getRemainingTime(seconds: number): RemainingTimeValue { 15 | const hh = Math.floor(seconds / 3600) 16 | const mm = Math.floor((seconds % 3600 / 60)) 17 | const ss = Math.floor(seconds % 60) 18 | 19 | return { 20 | hours: hh, 21 | minutes: mm, 22 | seconds: ss 23 | } 24 | } -------------------------------------------------------------------------------- /api/app/Http/Requests/GetPartyLogs.php: -------------------------------------------------------------------------------- 1 | 'numeric' 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/screens/app.settings-faq/style.css: -------------------------------------------------------------------------------- 1 | .app-settings-faq-heading { 2 | padding-top: 24px; 3 | margin-bottom: 24px; 4 | text-align: center; 5 | } 6 | 7 | .app-settings-faq-heading > img { 8 | max-width: 160px; 9 | margin-bottom: 24px; 10 | } 11 | 12 | .app-settings-faq-heading > .text { 13 | font-size: 24px; 14 | } 15 | 16 | /** 17 | * FAQ Text 18 | */ 19 | 20 | .app-settings-faq-text { 21 | line-height: 1.5; 22 | } 23 | 24 | .app-settings-faq-text a { 25 | text-decoration: none; 26 | color: inherit; 27 | border-bottom: 1px dashed var(--color-black-5); 28 | } 29 | 30 | .app-settings-faq-text:not(:last-child) { 31 | margin-bottom: 16px; 32 | } -------------------------------------------------------------------------------- /ui/src/components/UiPresenceAvatar/style.css: -------------------------------------------------------------------------------- 1 | .ui-presence-avatar { 2 | position: relative; 3 | opacity: 0.5; 4 | } 5 | 6 | .ui-presence-avatar > .status { 7 | position: absolute; 8 | bottom: -4px; 9 | right: -4px; 10 | height: 16px; 11 | width: 16px; 12 | background: var(--color-black-5); 13 | border: 2px solid var(--color-black-2); 14 | border-radius: 50%; 15 | } 16 | 17 | .ui-presence-avatar.is-online { 18 | opacity: 1; 19 | transition: 200ms all ease; 20 | } 21 | 22 | .ui-presence-avatar.is-online > .status { 23 | background: var(--color-green); 24 | } 25 | 26 | .ui-presence-avatar.is-m > .status { 27 | height: 20px; 28 | width: 20px; 29 | } -------------------------------------------------------------------------------- /api/app/Http/Requests/SendPartyMessage.php: -------------------------------------------------------------------------------- 1 | 'required' 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 21 | return redirect('/home'); 22 | } 23 | 24 | return $next($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/components/UiSelect/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | 5 | type ElementProps = Omit, 'className'> 6 | 7 | interface OwnProps { 8 | mode?: 'dark' | 'light' 9 | } 10 | 11 | type Props = ElementProps & OwnProps 12 | 13 | function UiInput(props: Props) { 14 | return ( 15 |
18 | 20 | ) 21 | } 22 | 23 | export default React.forwardRef(UiInput) -------------------------------------------------------------------------------- /ui/src/screens/register/style.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 640px) { 2 | .registration-container { 3 | padding-top: 64px; 4 | padding-bottom: 64px; 5 | } 6 | } 7 | 8 | .registration-title { 9 | text-align: center; 10 | margin-bottom: 40px; 11 | } 12 | 13 | .registration-image { 14 | text-align: center; 15 | margin-bottom: 16px; 16 | } 17 | 18 | .registration-image > img { 19 | max-width: 162px; 20 | } 21 | 22 | .registration-action { 23 | margin-bottom: 16px; 24 | } 25 | 26 | .registration-byline { 27 | text-align: center; 28 | } 29 | 30 | .registration-byline a { 31 | color: var(--color-primary); 32 | text-decoration: none; 33 | } -------------------------------------------------------------------------------- /api/app/Http/Requests/UpdatePartyTime.php: -------------------------------------------------------------------------------- 1 | 'required|numeric' 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/resources/js/components/ExampleComponent.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 24 | -------------------------------------------------------------------------------- /api/app/Http/Requests/SendInvitation.php: -------------------------------------------------------------------------------- 1 | 'required|exists:users,id' 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/app/Http/Requests/StoreParty.php: -------------------------------------------------------------------------------- 1 | 'required|exists:show_videos,id' 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Handle Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /ui/src/components/UiInput/style.css: -------------------------------------------------------------------------------- 1 | .ui-input { 2 | display: block; 3 | padding: 0 16px; 4 | height: 50px; 5 | width: 100%; 6 | color: var(--color-black-5); 7 | background: var(--color-black-2); 8 | outline: 0; 9 | border: 1px solid transparent; 10 | border-radius: var(--border-radius); 11 | } 12 | 13 | .ui-input:focus { 14 | background: var(--color-black-1); 15 | border-color: rgb(29, 161, 242); 16 | } 17 | 18 | .ui-input, 19 | .ui-input:focus, 20 | .ui-input:hover { 21 | font-size: var(--font-size); 22 | } 23 | 24 | .ui-input.is-dark { 25 | background: var(--color-black-1); 26 | } 27 | 28 | .ui-input.is-round { 29 | border-radius: 25px; 30 | } -------------------------------------------------------------------------------- /ui/src/hooks/usePropRef.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | /** 4 | * Useful if you need access to your props in a custom document event listener. 5 | * 6 | * Unless you re-attach the events everytime your props change 7 | * (which is a deal breaker if you have object parameters), 8 | * you will get stale props (usually from the first mount). 9 | * 10 | * @usage const props = usePropRef(hookProps) 11 | */ 12 | function usePropRef(props: T) { 13 | const propRef = useRef(props) 14 | 15 | useEffect(() => { 16 | propRef.current = props 17 | }, [props]) 18 | 19 | return propRef 20 | } 21 | 22 | export { 23 | usePropRef, 24 | usePropRef as default 25 | } 26 | -------------------------------------------------------------------------------- /api/app/Http/Requests/ChangePartyVideo.php: -------------------------------------------------------------------------------- 1 | 'required|exists:show_videos,id' 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/resources/lang/en/auth.php: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 17 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 18 | 19 | ]; 20 | -------------------------------------------------------------------------------- /ui/src/components/UiSpacer/style.css: -------------------------------------------------------------------------------- 1 | .ui-spacer { 2 | margin-bottom: 8px; 3 | } 4 | 5 | .ui-spacer.is-size-2 { 6 | margin-bottom: 16px; 7 | } 8 | 9 | .ui-spacer.is-size-3 { 10 | margin-bottom: 24px; 11 | } 12 | 13 | .ui-spacer.is-size-4 { 14 | margin-bottom: 32px; 15 | } 16 | 17 | .ui-spacer.is-size-5 { 18 | margin-bottom: 40px; 19 | } 20 | 21 | .ui-spacer.is-size-6 { 22 | margin-bottom: 48px; 23 | } 24 | 25 | .ui-spacer.is-size-7 { 26 | margin-bottom: 56px; 27 | } 28 | 29 | .ui-spacer.is-size-8 { 30 | margin-bottom: 64px; 31 | } 32 | 33 | .ui-spacer.is-size-9 { 34 | margin-bottom: 72px; 35 | } 36 | 37 | .ui-spacer.is-size-10 { 38 | margin-bottom: 80px; 39 | } -------------------------------------------------------------------------------- /ui/src/types/react-input-slider/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-input-slider' { 2 | export interface SliderValue { 3 | x: number 4 | y: number 5 | } 6 | 7 | export interface SliderProps { 8 | axis?: 'x' | 'y' | 'xy' 9 | x?: number 10 | y?: number 11 | xmin?: number 12 | xmax?: number 13 | ymin?: number 14 | ymax?: number 15 | xstep?: number 16 | ystep?: number 17 | styles?: {} 18 | onClick?: (evt: React.ClickEvent) => void 19 | onChange?: (value: SliderValue) => void 20 | onDragEnd?: () => void 21 | } 22 | 23 | declare class Slider extends React.Component {} 24 | 25 | export default Slider 26 | } -------------------------------------------------------------------------------- /ui/src/hooks/useUpdateDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | function useUpdateDebounce (fn: () => void, ms: number, args: any[]) { 4 | if (ms === void 0) { ms = 0; } 5 | if (args === void 0) { args = []; } 6 | 7 | const isInitRef = useRef(false) 8 | 9 | useEffect(function () { 10 | if (!isInitRef.current) { 11 | isInitRef.current = true 12 | return 13 | } 14 | 15 | var handle = setTimeout(fn.bind(null, args), ms); 16 | return function () { 17 | // if args change then clear timeout 18 | clearTimeout(handle); 19 | }; 20 | }, args); 21 | }; 22 | 23 | export { 24 | useUpdateDebounce, 25 | useUpdateDebounce as default 26 | } -------------------------------------------------------------------------------- /ui/src/lib/pusher/index.ts: -------------------------------------------------------------------------------- 1 | import Pusher = require('pusher-js') 2 | import config from '~/config' 3 | import { AuthContext } from '~/contexts/Auth' 4 | 5 | let instance: Pusher.Pusher | null = null 6 | 7 | function pusher() { 8 | return instance 9 | } 10 | 11 | pusher.set = function(auth: AuthContext) { 12 | instance = new Pusher(config.api.pusherKey, { 13 | cluster: config.api.pusherCluster, 14 | encrypted: true, 15 | authEndpoint: `${config.api.baseUrl}/broadcasting/auth`, 16 | auth: { 17 | headers: { 18 | Authorization: `Bearer ${auth.token}` 19 | } 20 | } 21 | }) 22 | } 23 | 24 | pusher.unset = function() { 25 | instance = null 26 | } 27 | 28 | export default pusher 29 | -------------------------------------------------------------------------------- /ui/src/components/CountdownTimer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface Props { 4 | duration: number 5 | onComplete?: () => void 6 | } 7 | 8 | function useCountdownTimer(props: Props): number { 9 | const [remaining, setRemaining] = React.useState(props.duration) 10 | 11 | React.useEffect(() => { 12 | let interval = setInterval(() => { 13 | if (remaining === 0) { 14 | props.onComplete() 15 | clearInterval(interval) 16 | } else { 17 | setRemaining(remaining - 1) 18 | } 19 | }, 1000) 20 | 21 | return () => { 22 | clearInterval(interval) 23 | } 24 | }) 25 | 26 | return remaining 27 | } 28 | 29 | export default useCountdownTimer -------------------------------------------------------------------------------- /ui/src/components/UiButtonLoader/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import UiButton, { UiButtonProps } from '~/components/UiButton' 4 | import UiLoader from '~/components/UiLoader' 5 | 6 | type Props = UiButtonProps & { 7 | // @TODO We need to remove this. UiButtonProps already has this 8 | // but for some reason our typcheck won't work without this. 9 | disabled?: boolean 10 | isLoading?: boolean 11 | } 12 | 13 | function UiButtonLoader({ disabled, isLoading, children, ...props }: Props) { 14 | return ( 15 | 16 | {isLoading ? : children} 17 | 18 | ) 19 | } 20 | 21 | export default UiButtonLoader -------------------------------------------------------------------------------- /ui/src/components/UiFormGroup/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | 4 | interface Props { 5 | children: React.ReactNode 6 | label: string 7 | hint?: React.ReactNode 8 | } 9 | 10 | function UiFormGroup(props: Props) { 11 | return ( 12 |
13 | 14 | 15 | {props.children} 16 | 17 | {Boolean(props.hint) && ( 18 | 19 | 20 | 21 | 22 | 23 | {props.hint} 24 | 25 | )} 26 |
27 | ) 28 | } 29 | 30 | export default UiFormGroup -------------------------------------------------------------------------------- /ui/src/components/UiModal/style.css: -------------------------------------------------------------------------------- 1 | .ui-modal-body.is-open { 2 | overflow-y: hidden; 3 | } 4 | 5 | .ui-modal-overlay { 6 | position: fixed; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | bottom: 0; 11 | background: rgba(0,0,0,0.7); 12 | overflow-y: scroll; 13 | z-index: var(--zindex-modal); 14 | } 15 | 16 | .ui-modal { 17 | padding: 16px; 18 | outline: 0; 19 | } 20 | 21 | .ui-modal.has-padding { 22 | padding: 0; 23 | } 24 | 25 | .ui-modal-overlay.ReactModal__Overlay { 26 | opacity: 0; 27 | transition: opacity 200ms ease-in-out; 28 | } 29 | 30 | .ui-modal-overlay.ReactModal__Overlay--after-open { 31 | opacity: 1; 32 | } 33 | 34 | .ui-modal-overlay.ReactModal__Overlay--before-close { 35 | opacity: 0; 36 | } -------------------------------------------------------------------------------- /ui/src/lib/axios/index.ts: -------------------------------------------------------------------------------- 1 | import instance from './instance' 2 | import goify from '~/utils/goify' 3 | import './interceptor-app-init' 4 | import './interceptor-oauth' 5 | import './interceptor-expired-tokens' 6 | import './interceptor-toast-errors' 7 | // import './interceptor-error-handler' 8 | import './interceptor-pusher' 9 | 10 | export default { 11 | get(url: string, config?) { 12 | return goify(instance.get(url, config)) 13 | }, 14 | post(url: string, payload?, config?) { 15 | return goify(instance.post(url, payload, config)) 16 | }, 17 | put(url: string, payload?, config?) { 18 | return goify(instance.put(url, payload, config)) 19 | }, 20 | delete(url: string, config?) { 21 | return goify(instance.delete(url, config)) 22 | } 23 | } -------------------------------------------------------------------------------- /ui/src/components/RouterSwitch/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Switch } from 'react-router-dom' 3 | // import Error404 from './Error404' 4 | 5 | interface Props { 6 | children: React.ReactNode 7 | } 8 | 9 | /** 10 | * - Drills down the props (Switch doesn't). 11 | * - Provides an error 404 out of the box. 12 | */ 13 | class RouterSwitch extends React.Component { 14 | render() { 15 | const {children, ...rest} = this.props 16 | 17 | return ( 18 | 19 | {React.Children.map(children, (child: any) => 20 | React.cloneElement(child, rest) 21 | )} 22 | 23 | {/* */} 24 | 25 | ) 26 | } 27 | } 28 | 29 | export default RouterSwitch -------------------------------------------------------------------------------- /ui/src/hooks/useCountdownTimer.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface Props { 4 | duration: number 5 | onComplete?: () => void 6 | } 7 | 8 | function useCountdownTimer(props: Props): number { 9 | const [remaining, setRemaining] = React.useState(props.duration) 10 | 11 | React.useEffect(() => { 12 | let interval = setInterval(() => { 13 | if (remaining === 0) { 14 | props.onComplete() 15 | clearInterval(interval) 16 | } else { 17 | setRemaining(remaining - 1) 18 | } 19 | }, 1000) 20 | 21 | return () => { 22 | clearInterval(interval) 23 | } 24 | }) 25 | 26 | return remaining 27 | } 28 | 29 | export { 30 | useCountdownTimer, 31 | useCountdownTimer as default 32 | } -------------------------------------------------------------------------------- /api/app/Providers/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | 'App\Policies\ModelPolicy', 18 | ]; 19 | 20 | /** 21 | * Register any authentication / authorization services. 22 | * 23 | * @return void 24 | */ 25 | public function boot() 26 | { 27 | $this->registerPolicies(); 28 | 29 | Passport::routes(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/SubtitleSlot/utils.ts: -------------------------------------------------------------------------------- 1 | import s2o, { OriginalTrack } from '~/lib/srt2obj' 2 | import fromReadableTime from '~/utils/date/fromReadableTime' 3 | 4 | export interface Track { 5 | index: string 6 | timestamp: string 7 | start: number 8 | end: number 9 | text: string 10 | } 11 | 12 | /** 13 | * Transforms start & end from time string to number 14 | * 15 | * { start: '00:00:03,300' } -> { start: 3 } 16 | * { end: '00:00:04,000' } -> { end: 4 } 17 | */ 18 | export function srt2obj(str: string): Track[] { 19 | const result: OriginalTrack[] = s2o(str) 20 | 21 | return result.map((track: OriginalTrack) => { 22 | return { 23 | ...track, 24 | start: fromReadableTime(track.start), 25 | end: fromReadableTime(track.end) 26 | } 27 | }) 28 | } -------------------------------------------------------------------------------- /ui/src/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | api: { 3 | baseUrl: process.env.API_BASE_URL || 'http://localhost:8000', 4 | clientId: process.env.API_CLIENT_ID || '', 5 | clientSecret: process.env.API_CLIENT_SECRET || '', 6 | pusherAppId: process.env.PUSHER_APP_ID, 7 | pusherKey: process.env.PUSHER_KEY, 8 | pusherSecret: process.env.PUSHER_SECRET, 9 | pusherCluster: process.env.PUSHER_CLUSTER 10 | }, 11 | app: { 12 | title: 'Pulse', 13 | tagline: 'An annual bullet journal to help you track your progress. ', 14 | description: 'Pulse is an annual bullet journal to help you track your progress. ' 15 | }, 16 | links: { 17 | changelogs: 'https://www.notion.so/6591cc7552bf4b3f8f4a80ba7d7d9e00?v=ce00c057beda4f2495e733d5a38db73b' 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /misc/generate-text-files.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const { untrail } = require("./utils"); 4 | 5 | // Running --------------------- 6 | // node generate-text-files.js 7 | // Example ----------------- 8 | // node generate-text-files.js /home/srph/uploads/ 9 | // Output ------------------------ 10 | // [Generate TXT File] [AnimeRg] Arrietty 11 | // [Generate TXT File] [AnimeRg] My Neighbor Totoro 12 | const input = untrail(process.argv[2]); 13 | const ext = '.mp4' 14 | const files = fs 15 | .readdirSync(input) 16 | .filter(file => path.extname(file) === ext); 17 | 18 | files.forEach((file) => { 19 | const txt = path.join(input, `${file.replace(ext, '')}.txt`); 20 | fs.writeFileSync(txt, '') 21 | console.log(`[Generate TXT File] for ${file}`); 22 | }); 23 | -------------------------------------------------------------------------------- /api/app/Http/Controllers/Hooks/PusherHook.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $events = []; 15 | 16 | /** 17 | * 18 | */ 19 | public function __construct($hook) { 20 | $this->time_ms = $hook['time_ms']; 21 | 22 | $this->events = collect($hook['events'])->map(function($event) { 23 | return new PusherHookEvent($event); 24 | }); 25 | } 26 | 27 | /** 28 | * Handle the hook 29 | */ 30 | public function handle($callback) { 31 | $this->events->each(function($event) use($callback) { 32 | $callback($event); 33 | }); 34 | } 35 | } -------------------------------------------------------------------------------- /ui/src/utils/date/getFormattedRemainingTime.ts: -------------------------------------------------------------------------------- 1 | import { differenceInSeconds } from 'date-fns' 2 | import getRemainingTime from './getRemainingTime' 3 | 4 | type Payload = Date | string | number 5 | 6 | /** 7 | * Formats the remaining time (5h 4m 3s) 8 | * 9 | * @TODO Use `getFormattedDuration` instead of a repeat of what it does. 10 | */ 11 | export default function getFormattedRemainingTime(future: Payload, present: Payload) { 12 | const remaining = getRemainingTime(differenceInSeconds(future, present)) 13 | 14 | if (remaining.hours >= 1) { 15 | return `${remaining.hours}h ${remaining.minutes}m ${remaining.seconds}s` 16 | } 17 | 18 | if (remaining.minutes >= 1) { 19 | return `${remaining.minutes}m ${remaining.seconds}s` 20 | } 21 | 22 | return `${remaining.seconds}s` 23 | } -------------------------------------------------------------------------------- /api/database/migrations/2019_05_05_015450_create_genres_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->string('name'); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('genres'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/app/PartyMessage.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 33 | } 34 | 35 | public function log() { 36 | return $this->morphOne(PartyLog::class, 'loggable'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/PlayerStateBufferedIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import useUpdateEffect from 'react-use/lib/useUpdateEffect' 4 | import { useBufferState } from '~/hooks/useBufferState' 5 | 6 | interface Props { 7 | isPlaying: boolean 8 | } 9 | 10 | function PlayerStateBufferedIndicator(props: Props) { 11 | const [isOpen, setIsOpen] = useBufferState({ timeout: 400 }) 12 | 13 | useUpdateEffect(() => { 14 | setIsOpen() 15 | }, [props.isPlaying]) 16 | 17 | if (!isOpen) { 18 | return null 19 | } 20 | 21 | return ( 22 |
23 | {props.isPlaying ? : } 24 |
25 | ) 26 | } 27 | 28 | export default PlayerStateBufferedIndicator -------------------------------------------------------------------------------- /misc/rename-series.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { name, sort, untrail } = require('./utils') 4 | 5 | // Running --------------------- 6 | // node 7 | // Example ----------------- 8 | // node rename-series.js /home/srph/uploads/tarzan-x 9 | // Output ------------------------ 10 | // [Renamed] Tarzan X 1.mp4 to tarzan-x-s1-ep1.mp4 11 | const input = untrail(process.argv[2]) 12 | const files = fs.readdirSync(input) 13 | const dirname = name(input) 14 | 15 | sort(files.filter(file => path.extname(file) === '.mp4')) 16 | .forEach((file, i) => { 17 | const updated = path.join(input, `${dirname}-s1-ep${i + 1}.mp4`) 18 | const full = path.join(input, file) 19 | fs.renameSync(full, updated) 20 | console.log(`[Renamed] ${name(full)} to ${name(updated)}`) 21 | }) -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/SubtitleSlot/constants.ts: -------------------------------------------------------------------------------- 1 | import kebabCase from 'lodash.kebabcase' 2 | 3 | const raw = { 4 | TOP_LEFT_SUBTITLE: '{\\an7}', 5 | TOP_CENTER_SUBTITLE: '{\\an8}', 6 | TOP_RIGHT_SUBTITLE: '{\\an9}', 7 | MIDDLE_LEFT_SUBTITLE: '{\\an4}', 8 | MIDDLE_CENTER_SUBTITLE: '{\\an5}', 9 | MIDDLE_RIGHT_SUBTITLE: '{\\an6}', 10 | BOTTOM_LEFT_SUBTITLE: '{\\an1}', 11 | BOTTOM_CENTER_SUBTITLE: '{\\an2}', 12 | BOTTOM_RIGHT_SUBTITLE: '{\\an3}', 13 | } 14 | 15 | interface SubtitlePlacement { 16 | className: string 17 | indicator: string 18 | } 19 | 20 | const placements: SubtitlePlacement[] = Object.keys(raw) 21 | .map(key => ({ 22 | className: `is-${kebabCase(key)}`, 23 | indicator: raw[key] 24 | })) 25 | 26 | const HAS_PLACEMENT_PATTERN = /{\\an\d}/ 27 | 28 | export { placements, HAS_PLACEMENT_PATTERN } -------------------------------------------------------------------------------- /api/app/Http/Middleware/MemberOfParty.php: -------------------------------------------------------------------------------- 1 | route('party'); 19 | 20 | if (!$request->user()->isMemberOfParty($party)) { 21 | return response()->json([ 22 | 'error' => true, 23 | 'status' => 403, 24 | 'message' => 'It appears that you don\'t have sufficient permissions to access this content.' 25 | ], 403); 26 | } 27 | 28 | return $next($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /api/app/PartyActivity.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 33 | } 34 | 35 | public function log() { 36 | return $this->morphOne(PartyLog::class, 'loggable'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /misc/extract-subs-movie.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { execSync } = require('child_process') 4 | const { name } = require('./utils') 5 | 6 | // Running --------------------- 7 | // node 8 | // Example ----------------- 9 | // node rename-movie.js /home/srph/uploads/tarzan-x 10 | // Output ------------------------ 11 | // [Extracted Subtitle] tarzan-x.mkv to tarzan-x-en.srt 12 | const input = process.argv[2] 13 | const file = fs.readdirSync(input) 14 | .find(file => path.extname(file) === '.mkv') 15 | const dirname = name(input) 16 | 17 | const updated = path.join(input, `${dirname}-en.srt`) 18 | const full = path.join(input, file) 19 | execSync(`ffmpeg -i '${full}' '${updated}'`, { stdio: 'inherit' }) 20 | console.log(`[Extracted Subtitle] ${name(full)} to ${name(updated)}`) -------------------------------------------------------------------------------- /ui/src/components/UiSpacer/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | 5 | interface Props { 6 | size?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 7 | } 8 | 9 | function UiSpacer(props: Props) { 10 | return ( 11 |
23 | ) 24 | } 25 | 26 | UiSpacer.defaultProps = { 27 | size: 1 28 | } 29 | 30 | export default UiSpacer -------------------------------------------------------------------------------- /api/app/Http/Middleware/RecipientOfInvitation.php: -------------------------------------------------------------------------------- 1 | route('invitation'); 19 | 20 | if (!$request->user()->isRecipientOf($invitation)) { 21 | return response()->json([ 22 | 'error' => true, 23 | 'status' => 403, 24 | 'message' => 'It appears that you don\'t have sufficient permissions to access this content.' 25 | ], 403); 26 | } 27 | 28 | return $next($request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ui/src/components/UiButton/style.css: -------------------------------------------------------------------------------- 1 | .ui-button { 2 | display: inline-block; 3 | height: 26px; 4 | padding: 0 8px; 5 | line-height: 25px; 6 | text-transform: uppercase; 7 | font-weight: 500; 8 | letter-spacing: 1px; 9 | color: var(--color-white); 10 | background: var(--color-black-5); 11 | font-size: var(--font-size-h6); 12 | border-radius: var(--border-radius); 13 | border: 0; 14 | outline: 0; 15 | transition: 200ms all ease; 16 | cursor: pointer; 17 | } 18 | 19 | .ui-button.is-primary { 20 | background: var(--color-primary); 21 | } 22 | 23 | .ui-button.is-block { 24 | width: 100%; 25 | } 26 | 27 | .ui-button.is-l { 28 | height: 50px; 29 | line-height: 49px; 30 | } 31 | 32 | a.ui-button { 33 | text-decoration: none; 34 | text-align: center; 35 | } 36 | 37 | .ui-button:disabled { 38 | opacity: 0.6; 39 | } -------------------------------------------------------------------------------- /ui/src/screens/app/AppHeadingSettings/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import { Gateway } from 'react-gateway' 4 | import Helmet from 'react-helmet' 5 | import UiNavigation from '~/components/UiNavigation' 6 | import constants from '../constants' 7 | 8 | interface Props { 9 | title: string 10 | backUrl: string 11 | } 12 | 13 | function AppHeadingSettings(props: Props) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {props.title} 24 | 25 | 26 | ) 27 | } 28 | 29 | export default AppHeadingSettings -------------------------------------------------------------------------------- /api/database/migrations/2014_10_12_100000_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 18 | $table->string('token'); 19 | $table->timestamp('created_at')->nullable(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('password_resets'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/database/migrations/2019_08_20_211514_add_is_online_column_to_users_table.php: -------------------------------------------------------------------------------- 1 | boolean('is_online')->nullable()->default(false); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::table('users', function (Blueprint $table) { 29 | $table->dropColumn('is_online'); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 19 | SendEmailVerificationNotification::class, 20 | ], 21 | ]; 22 | 23 | /** 24 | * Register any events for your application. 25 | * 26 | * @return void 27 | */ 28 | public function boot() 29 | { 30 | parent::boot(); 31 | 32 | // 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/database/migrations/2019_05_04_101518_create_show_groups_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('show_id'); 19 | $table->string('title'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('show_groups'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api/database/migrations/2019_05_05_015536_create_genre_show_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('genre_id'); 19 | $table->integer('show_id'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('genre_show'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ui/src/utils/toSearchObject.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get' 2 | 3 | interface SearchMap { 4 | [key: string]: boolean 5 | } 6 | 7 | /** 8 | * Make it easy to search for an item inside an array 9 | * Use-case: Check if item in array A is in array B 10 | * 11 | * @input 12 | * [{ id: 5 }, { id: 6 }, { id: 7 }, { id: 32 }] 13 | * @output 14 | * { 5: true, 6: true, 7: true, 32: true } 15 | * 16 | * @example 17 | * const members = toSearchObject(party.members, 'id') 18 | * const isMember = members[user.id] || false // For short, you can omit `|| false` here 19 | * 20 | * @param array 21 | * @param property 22 | */ 23 | export default function toSearchObject(array: T[], property: string): SearchMap { 24 | return array.reduce((prev: SearchMap, current: T) => { 25 | prev[get(current, property)] = true 26 | return prev 27 | }, {}) 28 | } -------------------------------------------------------------------------------- /api/app/Rules/RequestAccessCode.php: -------------------------------------------------------------------------------- 1 | string('subtitle_url')->nullable(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::table('show_videos', function (Blueprint $table) { 29 | $table->dropColumn('subtitle_url'); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/resources/lang/en/passwords.php: -------------------------------------------------------------------------------- 1 | 'Passwords must be at least eight characters and match the confirmation.', 17 | 'reset' => 'Your password has been reset!', 18 | 'sent' => 'We have e-mailed your password reset link!', 19 | 'token' => 'This password reset token is invalid.', 20 | 'user' => "We can't find a user with that e-mail address.", 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /ui/src/components/UiSelect/style.css: -------------------------------------------------------------------------------- 1 | .ui-select { 2 | position: relative; 3 | } 4 | 5 | .ui-select > select { 6 | display: block; 7 | width: 100%; 8 | padding: 0 12px; 9 | height: var(--form-size); 10 | color: var(--color-white); 11 | background: transparent; 12 | border: 1px solid var(--color-black-5); 13 | border-radius: var(--border-radius); 14 | outline: 0; 15 | cursor: pointer; 16 | -webkit-appearance: none; 17 | appearance: none; 18 | } 19 | 20 | .ui-select > select::-ms-expand { 21 | display: none; 22 | } 23 | 24 | .ui-select > .caret { 25 | position: absolute; 26 | top: 12px; 27 | right: 12px; 28 | color: var(--color-white); 29 | pointer-events: none; 30 | } 31 | 32 | .ui-select.is-light > select { 33 | color: var(--color-black-4); 34 | border-color: var(--color-silver-3); 35 | } 36 | 37 | .ui-select.is-light > .caret { 38 | color: var(--color-silver-5); 39 | } -------------------------------------------------------------------------------- /api/database/migrations/2019_07_15_025106_add_is_dismissed_column__to_party_user_table.php: -------------------------------------------------------------------------------- 1 | boolean('is_dismissed')->nullable()->default(false); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::table('party_user', function (Blueprint $table) { 29 | $table->dropColumn('is_dismissed'); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/hooks/usePusher.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import pusher from '~/lib/pusher' 3 | 4 | /** 5 | * @REFACTOR Make a PusherProvider 6 | */ 7 | function usePusher(channelName: string, eventName: string, callback: Pusher.EventCallback, isDisabled: boolean = false) { 8 | useEffect(() => { 9 | let channel = null 10 | 11 | if (!isDisabled) { 12 | channel = pusher().subscribe(channelName) 13 | channel.bind(eventName, callback) 14 | } 15 | 16 | return () => { 17 | // @TODO Since we're using hooks and functions are recreated each render 18 | // let's check in the future the callback is being unbinded properly. 19 | if (channel) { 20 | channel.unbind(eventName, callback) 21 | pusher().unsubscribe(channelName) 22 | } 23 | } 24 | }, [isDisabled]) 25 | } 26 | 27 | export { 28 | usePusher, 29 | usePusher as default 30 | } -------------------------------------------------------------------------------- /ui/src/utils/date/toReadableTime.ts: -------------------------------------------------------------------------------- 1 | import getRemainingTime from './getRemainingTime' 2 | 3 | interface Settings { 4 | // This will allow us to display 00:00:32 5 | // i.e., toReadableTime(time, { max: duration > 3600 ? 'hh' : 'mm' }) 6 | max?: 'hh' | 'mm' 7 | } 8 | 9 | /** 10 | * Converts seconds to colon-separated time (e.g., 03:45:24, 45:34, 34) 11 | */ 12 | export default function toReadableTime(seconds: number, settings: Settings = {}): string { 13 | const remaining = getRemainingTime(seconds) 14 | const hh = remaining.hours 15 | const mm = remaining.minutes 16 | const ss = remaining.seconds 17 | 18 | if (hh >= 1 || settings.max === 'hh') { 19 | return [hh, mm, ss].map(t => String(t).padStart(2, '0')).join(':') 20 | } 21 | 22 | if (mm >= 1 || settings.max === 'mm') { 23 | return [mm, ss].map(t => String(t).padStart(2, '0')).join(':') 24 | } 25 | 26 | return `${ss}` 27 | } -------------------------------------------------------------------------------- /api/database/migrations/2019_05_04_100644_create_party_user_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('user_id'); 19 | $table->integer('party_id'); 20 | $table->boolean('is_active'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('party_user'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/assets/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ui/src/lib/pusher/PusherManager.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useEffect, useState } from 'react' 3 | import { useAuth } from '~/contexts/Auth' 4 | import pusher from './' 5 | 6 | /** 7 | * Setup the pusher instance 8 | */ 9 | function PusherManager({ children }: ReactComponentWrapper) { 10 | const [isInitialized, setIsInitialized] = useState(false) 11 | const auth = useAuth() 12 | 13 | useEffect(() => { 14 | if (auth.token) { 15 | pusher.set(auth) 16 | setIsInitialized(true) 17 | } else { 18 | pusher.unset() 19 | setIsInitialized(false) 20 | } 21 | 22 | return () => { 23 | pusher.unset() 24 | } 25 | }, [auth.token]) 26 | 27 | // For guests, we'll just show the view. Otherwise, we'll wait for Pusher to be initialized. 28 | return {!auth.token || isInitialized ? children : null} 29 | } 30 | 31 | export { PusherManager } 32 | -------------------------------------------------------------------------------- /api/database/migrations/2019_05_04_100855_create_party_logs_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('party_id'); 19 | $table->integer('loggable_id'); 20 | $table->string('loggable_type'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('party_logs'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/components/UiPresenceAvatar/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import UiAvatar from '~/components/UiAvatar' 4 | import cx from 'classnames' 5 | 6 | interface Props { 7 | user: AppUser 8 | size?: 'sm' | 'm' | 'l' | 'xl' 9 | // Used to check if a user is active in the party 10 | isActive?: boolean 11 | } 12 | 13 | function UiPresenceAvatar(props: Props) { 14 | const isActive = props.isActive != null ? props.isActive : props.user.is_online 15 | 16 | return ( 17 |
25 | 26 |
27 |
28 | ) 29 | } 30 | 31 | export default UiPresenceAvatar 32 | -------------------------------------------------------------------------------- /api/database/migrations/2019_05_05_042049_create_party_log_messages_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('user_id'); 19 | $table->integer('party_id'); 20 | $table->string('text'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('party_log_messages'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/app/Http/Requests/RegisterUser.php: -------------------------------------------------------------------------------- 1 | 'required|min:2', 29 | 'email' => 'required|email|unique:users', 30 | 'password' => 'required|min:8|confirmed', 31 | 'password_confirmation' => 'required|min:8', 32 | 'request_access_code' => ['required', new RequestAccessCode] 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/database/migrations/2019_05_05_042109_create_party_log_activities_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('user_id'); 19 | $table->integer('party_id'); 20 | $table->string('text'); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('party_log_activities'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/hooks/useBufferState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react' 2 | import useUpdateEffect from 'react-use/lib/useUpdateEffect' 3 | 4 | interface Props { 5 | timeout: number 6 | } 7 | 8 | type Payload = [boolean, () => void] 9 | 10 | function useBufferState(props: Props): Payload { 11 | const [state, internalSetState] = useState(false) 12 | const counterRef = useRef(0) 13 | const timeoutRef = useRef(null) 14 | 15 | function setState(): void { 16 | ++counterRef.current 17 | internalSetState(false) 18 | } 19 | 20 | useUpdateEffect(() => { 21 | internalSetState(true) 22 | 23 | timeoutRef.current = window.setTimeout(() => { 24 | internalSetState(false) 25 | }, props.timeout) 26 | 27 | return () => { 28 | window.clearTimeout(timeoutRef.current) 29 | } 30 | }, [counterRef.current]) 31 | 32 | return [state, setState] 33 | } 34 | 35 | export { 36 | useBufferState, 37 | useBufferState as default 38 | } -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/SeasonSelectionModal/style.css: -------------------------------------------------------------------------------- 1 | .watch-home-season-selection-overlay { 2 | background: var(--color-black-1); 3 | } 4 | 5 | @media (min-width: 640px) { 6 | .watch-home-season-selection-overlay { 7 | background: rgba(0,0,0,0.75); 8 | } 9 | } 10 | 11 | .watch-home-season-selection-modal { 12 | position: relative; 13 | } 14 | 15 | @media (min-width: 640px) { 16 | .watch-home-season-selection-modal { 17 | margin: 64px auto; 18 | width: 360px; 19 | background: var(--color-white); 20 | border-radius: var(--border-radius); 21 | } 22 | } 23 | 24 | .watch-home-season-selection-close { 25 | position: absolute; 26 | top: 16px; 27 | right: 16px; 28 | } 29 | 30 | .watch-home-season-selection-close-button { 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | height: 32px; 35 | width: 32px; 36 | color: var(--color-white); 37 | background: var(--color-black-5); 38 | border-radius: 50%; 39 | } -------------------------------------------------------------------------------- /ui/src/screens/app/index.tsx: -------------------------------------------------------------------------------- 1 | import './style' 2 | 3 | import * as React from 'react' 4 | import InvitationModal from './InvitationModal' 5 | import AppHeading from './AppHeading' 6 | 7 | import { useCallback } from 'react' 8 | import { usePusher } from '~/hooks/usePusher' 9 | import { useAuth } from '~/contexts/Auth' 10 | 11 | function App(props: ReactComponentWrapper) { 12 | const auth = useAuth() 13 | 14 | const noop = useCallback(() => {}, []) 15 | 16 | // We don't really care about `random-event`, we just want to subscribe to `presence-chat`. 17 | usePusher('presence-chat', 'random-event', noop, auth.isGuest) 18 | 19 | return ( 20 | 21 | {auth.isAuthenticated && ( 22 | 23 | 24 | 25 | 26 | )} 27 | 28 | {props.children} 29 | 30 | ) 31 | } 32 | 33 | export default App -------------------------------------------------------------------------------- /api/app/Http/Requests/UpdatePartyState.php: -------------------------------------------------------------------------------- 1 | 'required|boolean', 28 | // @TODO Should not be more than the current video's duration. 29 | // Currently applies for connection losses, even if we don't fully support that anyway haha. 30 | // Just to protect our data from becoming trashed. 31 | 'current_time' => 'required|numeric' 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/src/components/UiAvatar/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | import { getInitials, getBgFromInitials } from './utils' 5 | 6 | interface Props { 7 | img?: string 8 | user?: AppUser 9 | size?: 'sm' | 'm' | 'l' | 'xl' 10 | } 11 | 12 | function UiAvatar(props: Props) { 13 | const className = cx('ui-avatar', { 14 | 'is-sm': props.size === 'sm', 15 | 'is-m': props.size === 'm', 16 | 'is-l': props.size === 'l', 17 | 'is-xl': props.size === 'xl' 18 | }) 19 | 20 | if (props.user && !props.user.avatar) { 21 | const initials = getInitials(props.user.name) 22 | 23 | return ( 24 |
25 | {initials} 26 |
27 | ) 28 | } 29 | 30 | return Avatar 31 | } 32 | 33 | export default UiAvatar 34 | -------------------------------------------------------------------------------- /ui/src/screens/app.home/GuestHome/style.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 640px) { 2 | .guest-home-container { 3 | padding-top: 64px; 4 | padding-bottom: 64px; 5 | } 6 | } 7 | 8 | .guest-home-logo { 9 | padding-top: 16px; 10 | margin-bottom: 32px; 11 | text-align: center; 12 | } 13 | 14 | .guest-home-title { 15 | text-align: center; 16 | margin-bottom: 8px; 17 | color: var(--color-primary); 18 | } 19 | 20 | .guest-home-image { 21 | text-align: center; 22 | margin-bottom: 16px; 23 | } 24 | 25 | .guest-home-copy { 26 | font-size: var(--font-size-h4); 27 | margin-bottom: 32px; 28 | text-align: center; 29 | line-height: 1.4; 30 | } 31 | 32 | .guest-home-image > img { 33 | max-width: 162px; 34 | } 35 | 36 | .guest-home-action { 37 | margin-bottom: 16px; 38 | } 39 | 40 | .guest-home-byline { 41 | text-align: center; 42 | } 43 | 44 | .guest-home-byline a { 45 | color: var(--color-primary); 46 | text-decoration: none; 47 | } -------------------------------------------------------------------------------- /api/database/migrations/2019_06_20_142020_create_user_admin_record.php: -------------------------------------------------------------------------------- 1 | 'Tarka Ji', 20 | 'email' => 'admin@admin.com', 21 | 'email_verified_at' => now(), 22 | 'password' => 'admin', 23 | 'remember_token' => Str::random(10), 24 | 'is_admin' => true 25 | ]); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | // HAHA, fuck. 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/hooks/useFullscreen.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import screenfull, { Screenfull } from 'screenfull' 3 | 4 | type ReturnValue = [boolean, () => void] 5 | 6 | function useFullscreen(): ReturnValue { 7 | const sf = screenfull 8 | 9 | const [isFullscreen, setIsFullscreen] = useState(sf.isFullscreen) 10 | 11 | useEffect(() => { 12 | function handleScreenfullChange() { 13 | setIsFullscreen(sf.isFullscreen) 14 | } 15 | 16 | if (sf.enabled) { 17 | sf.on('change', handleScreenfullChange) 18 | } 19 | 20 | return () => { 21 | sf.off('change', handleScreenfullChange) 22 | } 23 | }, []) 24 | 25 | function toggleFullscreen() { 26 | if (!sf.enabled) { 27 | return 28 | } 29 | 30 | if (sf.isFullscreen) { 31 | sf.exit() 32 | } else { 33 | sf.request() 34 | } 35 | } 36 | 37 | return [isFullscreen, toggleFullscreen] 38 | } 39 | 40 | export { 41 | useFullscreen, 42 | useFullscreen as default 43 | } -------------------------------------------------------------------------------- /api/app/Http/Controllers/Auth/ForgotPasswordController.php: -------------------------------------------------------------------------------- 1 | middleware('guest'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker $faker) { 20 | return [ 21 | 'name' => $faker->name, 22 | 'email' => $faker->unique()->safeEmail, 23 | 'email_verified_at' => now(), 24 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 25 | 'remember_token' => Str::random(10), 26 | ]; 27 | }); 28 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | 7 | LOG_CHANNEL=stack 8 | 9 | DB_CONNECTION=mysql 10 | DB_HOST=127.0.0.1 11 | DB_PORT=3306 12 | DB_DATABASE=homestead 13 | DB_USERNAME=homestead 14 | DB_PASSWORD=secret 15 | 16 | BROADCAST_DRIVER=log 17 | CACHE_DRIVER=file 18 | QUEUE_CONNECTION=sync 19 | SESSION_DRIVER=file 20 | SESSION_LIFETIME=120 21 | 22 | REDIS_HOST=127.0.0.1 23 | REDIS_PASSWORD=null 24 | REDIS_PORT=6379 25 | 26 | MAIL_DRIVER=smtp 27 | MAIL_HOST=smtp.mailtrap.io 28 | MAIL_PORT=2525 29 | MAIL_USERNAME=null 30 | MAIL_PASSWORD=null 31 | MAIL_ENCRYPTION=null 32 | 33 | AWS_ACCESS_KEY_ID= 34 | AWS_SECRET_ACCESS_KEY= 35 | AWS_DEFAULT_REGION=us-east-1 36 | AWS_BUCKET= 37 | 38 | PUSHER_APP_ID= 39 | PUSHER_APP_KEY= 40 | PUSHER_APP_SECRET= 41 | PUSHER_APP_CLUSTER=mt1 42 | 43 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" 44 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 45 | 46 | APP_CDN=https://caretv.sgp1.cdn.digitaloceanspaces.com/ 47 | APP_REQUEST_ACCESS_CODE= -------------------------------------------------------------------------------- /misc/extract-subs-movie-batch.js: -------------------------------------------------------------------------------- 1 | // --------------------- 2 | // @NOTE Never used hahahahaha 3 | // --------------------- 4 | const fs = require('fs') 5 | const path = require('path') 6 | const { execSync } = require('child_process') 7 | const { name, untrail } = require('./utils') 8 | 9 | // Running --------------------- 10 | // node 11 | // Example ----------------- 12 | // node extract-subs-movies.js /home/srph/uploads/ 13 | // Output ------------------------ 14 | // [Extracted] Arrietty to Arrietty-en.srt 15 | // [Extracted] Xyz to Xyz-en.srt 16 | const input = untrail(process.argv[2]) 17 | const files = fs.readdirSync(input) 18 | 19 | files.filter(file => path.extname(file) === '.mkv') 20 | .forEach((file, i) => { 21 | const updated = path.join(input, `${file.replace('.mkv', '')}-en.srt`) 22 | const full = path.join(input, file) 23 | execSync(`ffmpeg -i '${full}' '${updated}'`, { stdio: 'inherit' }) 24 | console.log(`[Extracted Subtitle] ${name(full)} to ${name(updated)}`) 25 | }) -------------------------------------------------------------------------------- /misc/extract-subs-series.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { execSync } = require('child_process') 4 | const { name, sort, untrail } = require('./utils') 5 | 6 | // Running --------------------- 7 | // node 8 | // Example ----------------- 9 | // node extract-srt-series.js /home/srph/uploads/kimetsu-no-yaiba 10 | // Output ------------------------ 11 | // [Extracted] kimetsu-no-yaiba-1.mkv to kimetsu-no-yaiba-1.srt 12 | // [Extracted] kimetsu-no-yaiba-2.mkv to kimetsu-no-yaiba-2.srt 13 | const input = untrail(process.argv[2]) 14 | const files = fs.readdirSync(input) 15 | const dirname = name(input) 16 | 17 | sort(files.filter(file => path.extname(file) === '.mkv')) 18 | .forEach((file, i) => { 19 | const updated = path.join(input, `${dirname}-s1-ep${i + 1}-en.srt`) 20 | const full = path.join(input, file) 21 | execSync(`ffmpeg -i "${full}" "${updated}"`, { stdio: 'inherit' }) 22 | console.log(`[Extracted] ${name(full)} to ${name(updated)}`) 23 | }) -------------------------------------------------------------------------------- /api/database/migrations/2019_05_04_100523_create_parties_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->integer('show_video_id'); 19 | $table->boolean('is_playing'); 20 | $table->boolean('is_expired'); 21 | $table->integer('current_time'); 22 | $table->timestamp('last_activity_at'); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('parties'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/app/Http/Requests/DoInvitation.php: -------------------------------------------------------------------------------- 1 | route('invitation'); 17 | // Really not sure with the expirations for now. 18 | return $invitation->action == 'pending' || $invitation->action == null; 19 | 20 | // @TODO Check if user is recipient/sender 21 | // @TODO Make sure accept/decline is not done by sender? 22 | // return !$invitation->action && Carbon::now()->lessThan($invitation->expires_at); 23 | } 24 | 25 | /** 26 | * Get the validation rules that apply to the request. 27 | * 28 | * @return array 29 | */ 30 | public function rules() 31 | { 32 | return [ 33 | // 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | command('inspire') 28 | // ->hourly(); 29 | } 30 | 31 | /** 32 | * Register the commands for the application. 33 | * 34 | * @return void 35 | */ 36 | protected function commands() 37 | { 38 | $this->load(__DIR__.'/Commands'); 39 | 40 | require base_path('routes/console.php'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/screens/app.watch.home/PlayerStateBufferedIndicator/style.css: -------------------------------------------------------------------------------- 1 | @keyframes app-watch-player-state-buffered-indicator-fade-out { 2 | 0% { 3 | opacity: 1; 4 | } 5 | 6 | 100% { 7 | opacity: 0; 8 | } 9 | } 10 | 11 | .app-watch-player-state-buffered-indicator { 12 | position: absolute; 13 | top: calc(50% - 60px); 14 | left: calc(50% - 60px); 15 | height: 120px; 16 | width: 120px; 17 | display: none; 18 | align-items: center; 19 | justify-content: center; 20 | font-size: 48px; 21 | background: rgba(0,0,0,0.75); 22 | border-radius: 50%; 23 | color: var(--color-white); 24 | pointer-events: none; 25 | user-select: none; 26 | animation-name: app-watch-player-state-buffered-indicator-fade-out; 27 | animation-duration: 400ms; 28 | animation-iteration-count: 1; 29 | animation-timing-function: ease-in-out; 30 | opacity: 0; 31 | z-index: var(--zindex-player-state-buffered-indicator); 32 | } 33 | 34 | @media (min-width: 992px) { 35 | .app-watch-player-state-buffered-indicator { 36 | display: flex; 37 | } 38 | } -------------------------------------------------------------------------------- /ui/src/components/UiButton/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | import * as React from 'react' 3 | import cx from 'classnames' 4 | import { Link, LinkProps } from 'react-router-dom' 5 | 6 | interface OwnProps { 7 | variant?: 'default' | 'primary' 8 | size?: 's' | 'l' 9 | link?: boolean 10 | block?: boolean 11 | } 12 | 13 | type ButtonAttributes = Omit, 'className'> 14 | type LinkAttributes = Omit 15 | type Attributes = ButtonAttributes | LinkAttributes 16 | type Props = OwnProps & Attributes 17 | 18 | function UiButton({ variant, size, block, link, ...props }: Props) { 19 | const cls = cx("ui-button", { 20 | 'is-primary': variant === 'primary', 21 | 'is-block': block, 22 | 'is-l': size === 'l', 23 | }) 24 | 25 | return link 26 | ? 27 | :