├── .devcontainer
├── devcontainer.json
└── postunpack.sh
├── .github
├── ISSUE_TEMPLATE
│ ├── 1-bug-report.yaml
│ └── config.yml
├── labeler.yml
├── renovate.json
└── workflows
│ ├── TODO.md
│ ├── __automation.yml
│ ├── __codeql.yml
│ ├── __deploy.yml
│ ├── __job_messages.yml
│ ├── __package.yml
│ ├── __quality_checks.yml
│ ├── pull_request.yml
│ ├── push_release.yml
│ └── schedule.yml
├── .npmrc
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── frontend
├── eslint.config.js
├── index.html
├── locales
│ ├── ar.json
│ ├── ca.json
│ ├── cs.json
│ ├── da.json
│ ├── de.json
│ ├── el.json
│ ├── en.json
│ ├── eo.json
│ ├── es.json
│ ├── et.json
│ ├── fi.json
│ ├── fil.json
│ ├── fr.json
│ ├── he.json
│ ├── hu.json
│ ├── id.json
│ ├── it.json
│ ├── ja.json
│ ├── kk.json
│ ├── ko.json
│ ├── lt.json
│ ├── ml.json
│ ├── mn.json
│ ├── nb-NO.json
│ ├── nl.json
│ ├── nn.json
│ ├── pa.json
│ ├── pl.json
│ ├── pt-BR.json
│ ├── pt.json
│ ├── ro.json
│ ├── ru.json
│ ├── sk.json
│ ├── sl.json
│ ├── sr-Latn.json
│ ├── sv.json
│ ├── ta.json
│ ├── th.json
│ ├── tr.json
│ ├── uk.json
│ ├── ur.json
│ ├── vi.json
│ ├── zh-CN.json
│ └── zh-TW.json
├── package.json
├── public
│ ├── config.json
│ ├── favicon.ico
│ ├── icon.svg
│ └── robots.txt
├── scripts
│ ├── paths.ts
│ ├── plugins
│ │ ├── analysis.ts
│ │ └── chunking.ts
│ └── virtual-modules.ts
├── src
│ ├── App.vue
│ ├── assets
│ │ └── styles
│ │ │ ├── global.css
│ │ │ ├── index.css
│ │ │ └── splashscreen.css
│ ├── components
│ │ ├── Buttons
│ │ │ ├── FilterButton.vue
│ │ │ ├── LikeButton.vue
│ │ │ ├── MarkPlayedButton.vue
│ │ │ ├── Playback
│ │ │ │ ├── NextTrackButton.vue
│ │ │ │ ├── PlayButton.vue
│ │ │ │ ├── PlayPauseButton.vue
│ │ │ │ ├── PlaybackSettingsButton.vue
│ │ │ │ ├── PreviousTrackButton.vue
│ │ │ │ ├── RepeatButton.vue
│ │ │ │ └── ShuffleButton.vue
│ │ │ ├── QueueButton.vue
│ │ │ ├── ScrollToTopButton.vue
│ │ │ ├── SortButton.vue
│ │ │ ├── SubtitleSelectionButton.vue
│ │ │ └── TypeButton.vue
│ │ ├── Dialogs
│ │ │ ├── ConfirmDialog.vue
│ │ │ └── GenericDialog.vue
│ │ ├── Forms
│ │ │ ├── AddServerForm.vue
│ │ │ └── LoginForm.vue
│ │ ├── Item
│ │ │ ├── Card
│ │ │ │ ├── GenericItemCard.vue
│ │ │ │ ├── ItemCard.vue
│ │ │ │ └── ServerCard.vue
│ │ │ ├── CollectionTabs.vue
│ │ │ ├── Identify
│ │ │ │ ├── IdentifyDialog.vue
│ │ │ │ └── IdentifyResults.vue
│ │ │ ├── ItemGrid.vue
│ │ │ ├── ItemMenu.vue
│ │ │ ├── MediaDetail
│ │ │ │ ├── MediaDetailAttr.vue
│ │ │ │ ├── MediaDetailContent.vue
│ │ │ │ └── MediaDetailDialog.vue
│ │ │ ├── MediaInfo.vue
│ │ │ ├── MediaSourceSelector.vue
│ │ │ ├── MediaStreamSelector.vue
│ │ │ ├── Metadata
│ │ │ │ ├── DateInput.vue
│ │ │ │ ├── ImageEditor.vue
│ │ │ │ ├── ImageSearch.vue
│ │ │ │ ├── MetadataEditor.vue
│ │ │ │ ├── MetadataEditorDialog.vue
│ │ │ │ ├── PersonEditor.vue
│ │ │ │ └── RefreshMetadataDialog.vue
│ │ │ ├── PeopleList.vue
│ │ │ ├── RelatedItems.vue
│ │ │ ├── SeasonTabs.vue
│ │ │ └── WatchedIndicator.vue
│ │ ├── Layout
│ │ │ ├── AppBar
│ │ │ │ ├── AppBar.vue
│ │ │ │ ├── AppBarButtonLayout.vue
│ │ │ │ ├── Buttons
│ │ │ │ │ ├── CastButton.vue
│ │ │ │ │ ├── TaskManagerButton.vue
│ │ │ │ │ └── UserButton.vue
│ │ │ │ └── SearchField.vue
│ │ │ ├── Artist
│ │ │ │ └── ArtistTab.vue
│ │ │ ├── AudioControls.vue
│ │ │ ├── Backdrop.vue
│ │ │ ├── Carousel
│ │ │ │ ├── Carousel.vue
│ │ │ │ ├── CarouselProgressBar.vue
│ │ │ │ └── Item
│ │ │ │ │ ├── ItemsCarousel.vue
│ │ │ │ │ └── ItemsCarouselTitle.vue
│ │ │ ├── Images
│ │ │ │ ├── Blurhash
│ │ │ │ │ ├── BlurhashCanvas.vue
│ │ │ │ │ ├── BlurhashImage.vue
│ │ │ │ │ └── BlurhashImageIcon.vue
│ │ │ │ └── UserImage.vue
│ │ │ ├── ItemCols.vue
│ │ │ ├── Navigation
│ │ │ │ ├── CommitLink.vue
│ │ │ │ └── NavigationDrawer.vue
│ │ │ ├── SettingsPage.vue
│ │ │ ├── SwiperSection.vue
│ │ │ ├── TimeSlider.vue
│ │ │ └── VolumeSlider.vue
│ │ ├── Playback
│ │ │ ├── DraggableQueue.vue
│ │ │ ├── MiniVideoPlayer.vue
│ │ │ ├── MusicVisualizer.vue
│ │ │ ├── PlayerElement.vue
│ │ │ ├── SubtitleTrack.vue
│ │ │ ├── TrackList.vue
│ │ │ └── UpNext.vue
│ │ ├── Selectors
│ │ │ └── FontSelector.vue
│ │ ├── Skeletons
│ │ │ ├── SkeletonCard.vue
│ │ │ └── SkeletonItemGrid.vue
│ │ ├── System
│ │ │ ├── AboutLinks.vue
│ │ │ ├── AddApiKey.vue
│ │ │ ├── LoadingIndicator.vue
│ │ │ ├── LocaleSwitcher.vue
│ │ │ └── Snackbar.vue
│ │ ├── Users
│ │ │ └── UserCard.vue
│ │ ├── Wizard
│ │ │ ├── WizardAdminAccount.vue
│ │ │ ├── WizardLanguage.vue
│ │ │ ├── WizardMetadata.vue
│ │ │ └── WizardRemoteAccess.vue
│ │ └── lib
│ │ │ ├── JApp.vue
│ │ │ ├── JHover.vue
│ │ │ ├── JImg.vue
│ │ │ ├── JSafeHtml.vue
│ │ │ ├── JSlot.vue
│ │ │ ├── JSplashscreen.vue
│ │ │ ├── JTransition.vue
│ │ │ ├── JView.vue
│ │ │ └── JVirtual
│ │ │ ├── JVirtual.vue
│ │ │ ├── j-virtual.worker.ts
│ │ │ └── pipeline.ts
│ ├── composables
│ │ ├── apis.ts
│ │ ├── backdrop.ts
│ │ ├── page-title.ts
│ │ ├── use-confirm-dialog.ts
│ │ ├── use-datefns.ts
│ │ ├── use-loading.ts
│ │ ├── use-pausable-effect.ts
│ │ ├── use-playback.ts
│ │ ├── use-responsive-classes.ts
│ │ └── use-snackbar.ts
│ ├── layouts
│ │ ├── default.vue
│ │ ├── fullpage.vue
│ │ └── server.vue
│ ├── main.ts
│ ├── pages
│ │ ├── artist
│ │ │ └── [itemId].vue
│ │ ├── genre
│ │ │ └── [itemId].vue
│ │ ├── index.vue
│ │ ├── item
│ │ │ └── [itemId].vue
│ │ ├── library
│ │ │ └── [itemId].vue
│ │ ├── metadata.vue
│ │ ├── musicalbum
│ │ │ └── [itemId].vue
│ │ ├── person
│ │ │ └── [itemId].vue
│ │ ├── playback
│ │ │ ├── music.vue
│ │ │ └── video.vue
│ │ ├── search.vue
│ │ ├── series
│ │ │ └── [itemId].vue
│ │ ├── server
│ │ │ ├── add.vue
│ │ │ ├── login.vue
│ │ │ └── select.vue
│ │ ├── settings
│ │ │ ├── apikeys.vue
│ │ │ ├── devices.vue
│ │ │ ├── index.vue
│ │ │ ├── logs-and-activity.vue
│ │ │ ├── subtitles.vue
│ │ │ └── users
│ │ │ │ ├── [id].vue
│ │ │ │ ├── index.vue
│ │ │ │ └── new.vue
│ │ └── wizard.vue
│ ├── plugins
│ │ ├── directives.ts
│ │ ├── i18n.ts
│ │ ├── remote
│ │ │ ├── auth.ts
│ │ │ ├── axios.ts
│ │ │ ├── index.ts
│ │ │ ├── sdk
│ │ │ │ ├── index.ts
│ │ │ │ └── sdk-utils.ts
│ │ │ ├── socket.ts
│ │ │ └── types.d.ts
│ │ ├── router
│ │ │ ├── index.ts
│ │ │ └── middlewares
│ │ │ │ ├── admin-pages.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── meta.ts
│ │ │ │ ├── playback.ts
│ │ │ │ └── validate.ts
│ │ ├── vuetify.ts
│ │ └── workers
│ │ │ ├── blurhash-decoder.worker.ts
│ │ │ ├── canvas-drawer.worker.ts
│ │ │ ├── generic.worker.ts
│ │ │ ├── generic
│ │ │ └── subtitles.ts
│ │ │ └── index.ts
│ ├── splashscreen.ts
│ ├── store
│ │ ├── api.ts
│ │ ├── client-settings
│ │ │ ├── index.ts
│ │ │ └── subtitle-settings.ts
│ │ ├── index.ts
│ │ ├── playback-manager.ts
│ │ ├── player-element.ts
│ │ ├── super
│ │ │ ├── common-store.ts
│ │ │ └── synced-store.ts
│ │ └── task-manager.ts
│ └── utils
│ │ ├── browser-detection.ts
│ │ ├── data-manipulation.ts
│ │ ├── external-config.ts
│ │ ├── forms.ts
│ │ ├── html.ts
│ │ ├── i18n.ts
│ │ ├── images.ts
│ │ ├── items.ts
│ │ ├── playback-profiles
│ │ ├── directplay-profile.ts
│ │ ├── helpers
│ │ │ ├── audio-formats.ts
│ │ │ ├── codec-profiles.ts
│ │ │ ├── fmp4-audio-formats.ts
│ │ │ ├── fmp4-video-formats.ts
│ │ │ ├── hls-formats.ts
│ │ │ ├── mp4-audio-formats.ts
│ │ │ ├── mp4-video-formats.ts
│ │ │ ├── transcoding-formats.ts
│ │ │ ├── ts-audio-formats.ts
│ │ │ ├── ts-video-formats.ts
│ │ │ ├── webm-audio-formats.ts
│ │ │ └── webm-video-formats.ts
│ │ ├── index.ts
│ │ ├── subtitle-profile.ts
│ │ └── transcoding-profile.ts
│ │ ├── time.ts
│ │ └── validation.ts
├── tsconfig.json
├── types
│ ├── global
│ │ ├── attributes.d.ts
│ │ ├── components.d.ts
│ │ ├── plugins.d.ts
│ │ ├── routes.d.ts
│ │ └── util.d.ts
│ └── modules
│ │ └── virtual.d.ts
├── uno.config.ts
└── vite.config.ts
├── package-lock.json
├── package.json
└── packaging
├── deb
├── README.md
├── debian
│ ├── changelog
│ ├── compat
│ ├── conffiles
│ ├── control
│ ├── copyright
│ ├── gbp.conf
│ ├── install
│ ├── po
│ │ ├── POTFILES.in
│ │ └── templates.pot
│ ├── rules
│ └── source
│ │ ├── format
│ │ └── options
└── root
├── docker
├── Dockerfile
├── README.md
└── contents
│ ├── docker-entrypoint.sh
│ ├── nginx.conf
│ ├── postunpack.sh
│ └── setup.sh
└── tauri
├── Cargo.lock
├── Cargo.toml
├── README.md
├── apt_packages
├── package.json
├── rustfmt.toml
├── src
├── build.rs
└── main.rs
└── tauri.conf.json
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jellyfin-vue Codespace (with support for Tauri and Docker development)",
3 | "image": "node:20-slim",
4 | "features": {
5 | "ghcr.io/devcontainers/features/rust:1": {
6 | "profile": "default"
7 | },
8 | "ghcr.io/devcontainers/features/docker-in-docker:2": {
9 | "installDockerComposeSwitch": false
10 | },
11 | "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {},
12 | "ghcr.io/devcontainers/features/github-cli:1": {}
13 | },
14 | "forwardPorts": [3000],
15 | "portsAttributes": {
16 | "3000": {
17 | "label": "Vite server",
18 | "onAutoForward": "notify"
19 | }
20 | },
21 | "postCreateCommand": {
22 | "npm": "npm ci --no-audit",
23 | "no-yarn": "unset YARN_VERSION && rm -rf /opt/yarn*",
24 | "use-bash": "rm -rf /bin/sh && ln -s /bin/bash /bin/sh",
25 | "git-config": "git config --global core.editor 'code --wait'",
26 | "postunpack": ".devcontainer/postunpack.sh"
27 | },
28 | "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension",
29 | "hostRequirements": { "cpus": 4, "memory": "8gb" }
30 | }
31 |
--------------------------------------------------------------------------------
/.devcontainer/postunpack.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | ## Install Tauri dependencies
4 | apt update
5 | apt install -y --no-install-recommends $(cat packaging/tauri/apt_packages)
6 | rm -rf /var/lib/apt/lists /var/cache/apt/archives
7 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/1-bug-report.yaml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: You have noticed a general issue or regression, and would like to report it
3 | labels: [bug]
4 |
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thank you for taking the time to fill out this bug report!
10 | Please note that contributor time is limited, and we might not get back to you immediately.
11 | Also make sure to check if [an existing issue](https://github.com/jellyfin/jellyfin-vue/issues) matches yours.
12 | - type: textarea
13 | attributes:
14 | label: Description of the bug
15 | description: Provide a clear and concise description of the bug.
16 | validations:
17 | required: true
18 | - type: textarea
19 | attributes:
20 | label: Steps to reproduce
21 | description: Explain how a maintainer can reliably reproduce the bug.
22 | validations:
23 | required: true
24 | - type: textarea
25 | attributes:
26 | label: Expected behavior
27 | description: Provide a clear and concise description of what you expected to happen.
28 | validations:
29 | required: true
30 | - type: textarea
31 | attributes:
32 | label: Logs
33 | description: If relevant, provide **browser** logs indicating an error.
34 | render: text
35 | - type: textarea
36 | attributes:
37 | label: Screenshots
38 | description: If applicable, add screenshots to help explain your problem.
39 | - type: dropdown
40 | attributes:
41 | label: Platform
42 | options:
43 | - Linux
44 | - Windows
45 | - macOS
46 | - Android
47 | - iOS
48 | validations:
49 | required: true
50 | - type: dropdown
51 | attributes:
52 | label: Browser
53 | options:
54 | - Firefox
55 | - Chrome
56 | - Safari
57 | - Edge
58 | - Other (Please specify in the "additional context" section)
59 | validations:
60 | required: true
61 | - type: input
62 | attributes:
63 | label: Jellyfin server version
64 | placeholder: 10.7.6
65 | validations:
66 | required: true
67 | - type: textarea
68 | attributes:
69 | label: Additional context
70 | description: If necessary, provide any further context or information.
71 | render: text
72 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Feature Request
4 | url: https://features.jellyfin.org/
5 | about: Please head over to our feature request hub to vote on or submit a feature.
6 | - name: Help Or Question
7 | url: https://matrix.to/#/#jellyfin-troubleshooting:matrix.org
8 | about: Please join the troubleshooting Matrix channel to get some help.
9 |
--------------------------------------------------------------------------------
/.github/labeler.yml:
--------------------------------------------------------------------------------
1 | tests:
2 | - changed-files:
3 | - any-glob-to-any-file:
4 | - '**/*.spec.*'
5 |
6 | ci:
7 | - changed-files:
8 | - any-glob-to-any-file:
9 | - '.github/workflows/**/**'
10 |
11 | 'frontend:store':
12 | - changed-files:
13 | - any-glob-to-any-file:
14 | - 'frontend/store/**/*.ts'
15 |
16 | 'frontend:plugins':
17 | - changed-files:
18 | - any-glob-to-any-file:
19 | - 'frontend/plugins/**/*.ts'
20 |
21 | vue:
22 | - changed-files:
23 | - any-glob-to-any-file:
24 | - '**/*.vue'
25 |
26 | packaging:
27 | - changed-files:
28 | - any-glob-to-any-file:
29 | - 'packaging/**/**'
30 |
31 | ui:
32 | - changed-files:
33 | - any-glob-to-any-file:
34 | - 'frontend/components/**/*.vue'
35 | - 'frontend/layouts/**/*.vue'
36 | - 'frontend/pages/**/*.vue'
37 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "timezone": "Europe/Madrid",
3 | "packageRules": [
4 | {
5 | "matchManagers": [
6 | "npm"
7 | ],
8 | "matchDepTypes": [
9 | "devDependencies"
10 | ],
11 | "groupName": "npm (development)"
12 | },
13 | {
14 | "matchManagers": [
15 | "npm"
16 | ],
17 | "matchDepTypes": [
18 | "dependencies"
19 | ],
20 | "groupName": "npm (runtime)"
21 | },
22 | {
23 | "matchManagers": [
24 | "github-actions"
25 | ],
26 | "separateMajorMinor": false,
27 | "groupName": "ci"
28 | },
29 | {
30 | "matchFileNames": [
31 | "packaging/tauri/**"
32 | ],
33 | "separateMajorMinor": false,
34 | "groupName": "tauri"
35 | },
36 | {
37 | "matchUpdateTypes": [
38 | "lockFileMaintenance"
39 | ],
40 | "groupName": "lockfiles",
41 | "dependencyDashboardApproval": true
42 | }
43 | ],
44 | "dependencyDashboard": true,
45 | "prConcurrentLimit": 0,
46 | "prHourlyLimit": 0,
47 | "osvVulnerabilityAlerts": true,
48 | "vulnerabilityAlerts": {
49 | "enabled": true,
50 | "groupName": "vulnerable",
51 | "labels": [
52 | "security",
53 | "dependencies"
54 | ]
55 | },
56 | "ignoreDeps": [
57 | "npm",
58 | "node",
59 | "vuetify"
60 | ],
61 | "enabledManagers": [
62 | "npm",
63 | "github-actions",
64 | "cargo"
65 | ],
66 | "labels": [
67 | "dependencies"
68 | ],
69 | "rebaseWhen": "behind-base-branch",
70 | "rangeStrategy": "pin",
71 | "lockFileMaintenance": {
72 | "enabled": true,
73 | "groupName": "lockfiles",
74 | "schedule": [
75 | "every month"
76 | ]
77 | },
78 | "assignees": [
79 | "ferferga",
80 | "ThibaultNocchi"
81 | ],
82 | "reviewers": [
83 | "ferferga",
84 | "ThibaultNocchi"
85 | ],
86 | "extends": [
87 | "mergeConfidence:age-confidence-badges"
88 | ]
89 | }
90 |
--------------------------------------------------------------------------------
/.github/workflows/TODO.md:
--------------------------------------------------------------------------------
1 | * Reusable workflows should be under a `reusable` folder. Track https://github.com/orgs/community/discussions/10773. Right now, they're prefixed with `__`.
2 |
--------------------------------------------------------------------------------
/.github/workflows/__automation.yml:
--------------------------------------------------------------------------------
1 | name: Automation 🎛️
2 |
3 | on:
4 | workflow_call:
5 |
6 | jobs:
7 | label:
8 | name: Label conflicted PRs 🏷️⛔
9 | if: ${{ always() && !cancelled() }}
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Check all PRs for merge conflicts and label them ⛔
14 | uses: eps1lon/actions-label-merge-conflict@v3.0.2
15 | with:
16 | dirtyLabel: "merge conflict"
17 | repoToken: ${{ secrets.JF_BOT_TOKEN }}
18 |
--------------------------------------------------------------------------------
/.github/workflows/__codeql.yml:
--------------------------------------------------------------------------------
1 | name: GitHub CodeQL 🔬
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | commit:
7 | required: true
8 | type: string
9 | jobs:
10 | analyze:
11 | name: Analyze ${{ matrix.language }} 🔬
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | language:
18 | - javascript-typescript
19 |
20 | steps:
21 | - name: Checkout repository ⬇️
22 | uses: actions/checkout@v4.1.7
23 | with:
24 | ref: ${{ inputs.commit }}
25 | show-progress: false
26 |
27 | - name: Initialize CodeQL 🛠️
28 | uses: github/codeql-action/init@v3.26.6
29 | with:
30 | queries: security-and-quality
31 | languages: ${{ matrix.language }}
32 |
33 | - name: Autobuild 📦
34 | uses: github/codeql-action/autobuild@v3.26.6
35 |
36 | - name: Perform CodeQL Analysis 🧪
37 | uses: github/codeql-action/analyze@v3.26.6
38 | with:
39 | category: '/language:${{matrix.language}}'
40 |
--------------------------------------------------------------------------------
/.github/workflows/__deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy 🏗️
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | branch:
7 | required: true
8 | type: string
9 | commit:
10 | required: false
11 | type: string
12 | comment:
13 | required: false
14 | type: boolean
15 | artifact_name:
16 | required: false
17 | type: string
18 | default: frontend
19 |
20 | jobs:
21 | cf-pages:
22 | name: CloudFlare Pages 📃
23 | runs-on: ubuntu-latest
24 | environment:
25 | name: ${{ inputs.branch == 'master' && 'Production' || 'Preview' }}
26 | url: ${{ steps.cf.outputs.deployment-url }}
27 | outputs:
28 | url: ${{ steps.cf.outputs.deployment-url }}
29 |
30 | steps:
31 | - name: Download workflow artifact ⬇️
32 | uses: actions/download-artifact@v4.1.8
33 | with:
34 | name: ${{ inputs.artifact_name }}
35 | path: dist
36 |
37 | - name: Publish to Cloudflare Pages 📃
38 | uses: cloudflare/wrangler-action@v3.7.0
39 | id: cf
40 | with:
41 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
42 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
43 | command: pages deploy dist --project-name=jf-vue --branch=${{ inputs.branch }}
44 |
45 | compose-comment:
46 | name: Compose and push comment 📝
47 | # Always run so the comment is composed for the workflow summary
48 | if: ${{ always() }}
49 | uses: ./.github/workflows/__job_messages.yml
50 | secrets: inherit
51 | needs:
52 | - cf-pages
53 |
54 | with:
55 | branch: ${{ inputs.branch }}
56 | commit: ${{ inputs.commit }}
57 | preview_url: ${{ needs.cf-pages.outputs.url }}
58 | in_progress: false
59 | comment: ${{ inputs.comment }}
60 |
--------------------------------------------------------------------------------
/.github/workflows/__quality_checks.yml:
--------------------------------------------------------------------------------
1 | name: Quality checks 👌🧪
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | commit:
7 | required: true
8 | type: string
9 | workflow_dispatch:
10 |
11 | jobs:
12 | dependency-review:
13 | name: Vulnerable dependencies 🔎
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout Repository
17 | uses: actions/checkout@v4.1.7
18 | with:
19 | ref: ${{ inputs.commit }}
20 | show-progress: false
21 |
22 | - name: Scan
23 | uses: actions/dependency-review-action@v4.3.4
24 | with:
25 | ## Workaround from https://github.com/actions/dependency-review-action/issues/456
26 | ## TODO: Remove when necessary
27 | base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
28 | head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
29 |
30 | quality:
31 | name: Run ${{ matrix.command }} 🕵️♂️
32 | runs-on: ubuntu-latest
33 | strategy:
34 | fail-fast: false
35 | matrix:
36 | command:
37 | - lint
38 | - check:types
39 | - analyze:cycles
40 | defaults:
41 | run:
42 | working-directory: frontend
43 |
44 | steps:
45 | - name: Checkout ⬇️
46 | uses: actions/checkout@v4.1.7
47 | with:
48 | ref: ${{ inputs.commit }}
49 | show-progress: false
50 |
51 | - name: Setup node environment ⚙️
52 | uses: actions/setup-node@v4.0.3
53 | with:
54 | node-version: 20
55 | check-latest: true
56 | cache: 'npm'
57 |
58 | - name: Install dependencies 📦
59 | run: npm ci --no-audit
60 |
61 | - name: Run ${{ matrix.command }} ⚙️
62 | run: npm run ${{ matrix.command }}
63 |
64 | commits_checks:
65 | name: Commit linting 💬✅
66 | runs-on: ubuntu-latest
67 |
68 | steps:
69 | - name: Checkout ⬇️
70 | uses: actions/checkout@v4.1.7
71 | with:
72 | ref: ${{ inputs.commit }}
73 | show-progress: false
74 |
75 | - name: Check if all commits comply with the specification
76 | uses: webiny/action-conventional-commits@v1.3.0
77 |
78 | - name: Check for merge commits
79 | run: |
80 | git log --merges --oneline | grep -q "^" && exit 1 || exit 0
81 |
82 |
--------------------------------------------------------------------------------
/.github/workflows/push_release.yml:
--------------------------------------------------------------------------------
1 | name: Push & Release 🌍
2 |
3 | concurrency:
4 | group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref }}
5 | cancel-in-progress: true
6 |
7 | on:
8 | release:
9 | types:
10 | - released
11 | - prereleased
12 | push:
13 | branches:
14 | - master
15 | paths-ignore:
16 | - '**/*.md'
17 |
18 | jobs:
19 | automation:
20 | name: Automation 🎛️
21 | if: ${{ github.repository == 'jellyfin/jellyfin-vue' }}
22 | uses: ./.github/workflows/__automation.yml
23 | secrets: inherit
24 |
25 | main:
26 | name: ${{ github.event_name == 'push' && 'Unstable 🚀⚠️' || 'Stable 🏷️✅' }}
27 | uses: ./.github/workflows/__package.yml
28 | secrets: inherit
29 | # Needed for attestation publication
30 | permissions:
31 | id-token: write
32 | attestations: write
33 | with:
34 | commit: ${{ github.event_name == 'push' && github.sha }}
35 | is_prerelease: ${{ github.event_name == 'release' && github.event.action == 'prereleased' }}
36 | tag_name: ${{ github.event_name == 'release' && github.event.release.tag_name }}
37 | push: ${{ github.repository == 'jellyfin/jellyfin-vue' }}
38 |
39 | codeql:
40 | name: GitHub CodeQL 🔬
41 | uses: ./.github/workflows/__codeql.yml
42 | permissions:
43 | actions: read
44 | contents: read
45 | security-events: write
46 | with:
47 | commit: ${{ github.sha }}
48 |
49 | deploy:
50 | name: Deploy 🚀
51 | if: ${{ github.repository == 'jellyfin/jellyfin-vue' }}
52 | uses: ./.github/workflows/__deploy.yml
53 | needs:
54 | - main
55 | permissions:
56 | contents: read
57 | deployments: write
58 | secrets: inherit
59 | with:
60 | branch: ${{ github.ref_name }}
61 | comment: false
62 |
--------------------------------------------------------------------------------
/.github/workflows/schedule.yml:
--------------------------------------------------------------------------------
1 | name: Scheduled tasks 🕒
2 |
3 | on:
4 | schedule:
5 | - cron: 30 7 * * 6
6 | workflow_dispatch:
7 |
8 | jobs:
9 | codeql:
10 | name: GitHub CodeQL 🔬
11 | uses: ./.github/workflows/__codeql.yml
12 | permissions:
13 | actions: read
14 | contents: read
15 | security-events: write
16 | with:
17 | commit: ${{ github.sha }}
18 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | lockfile-version=3
2 | save-exact=true
3 | engine-strict=true
4 |
5 | ## Fix timeout errors in CI
6 | fetch-retries=15
7 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "antfu.iconify",
4 | "aaron-bond.better-comments",
5 | "eamodio.gitlens",
6 | "vue.volar",
7 | "dbaeumer.vscode-eslint",
8 | "lokalise.i18n-ally",
9 | "ryanluker.vscode-coverage-gutters",
10 | "yoavbls.pretty-ts-errors",
11 | "SonarSource.sonarlint-vscode"
12 | ],
13 | "unwantedRecommendations": [
14 | "octref.vetur",
15 | "esbenp.prettier-vscode",
16 | "vue.vscode-typescript-vue-plugin"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.codeActionsOnSave": {
4 | "source.fixAll.eslint": "always",
5 | "source.organizeImports": "never"
6 | },
7 | "[javascript]": {
8 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
9 | },
10 | "[typescript]": {
11 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
12 | },
13 | "[vue]": {
14 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
15 | },
16 | "editor.defaultFormatter": "dbaeumer.vscode-eslint",
17 | "i18n-ally.keystyle": "flat",
18 | "i18n-ally.sortKeys": true,
19 | "i18n-ally.sourceLanguage": "en",
20 | "i18n-ally.localesPaths": [
21 | "frontend/locales"
22 | ],
23 | "i18n-ally.enabledFrameworks": [
24 | "vue"
25 | ],
26 | "eslint.validate": [
27 | "vue",
28 | "javascript",
29 | "typescript"
30 | ],
31 | "eslint.format.enable": true,
32 | "eslint.useFlatConfig": true,
33 | "eslint.workingDirectories": [
34 | {
35 | "mode": "auto"
36 | }
37 | ],
38 | "vue.server.hybridMode": true,
39 | "vue.autoInsert.dotValue": true,
40 | "vue.inlayHints.missingProps": true,
41 | "vue.complete.casing.props": "kebab",
42 | "vue.complete.casing.tags": "pascal",
43 | "vue.format.script.initialIndent": true,
44 | "vue.format.style.initialIndent": true,
45 | "vue.inlayHints.inlineHandlerLeading": true,
46 | "vue.inlayHints.vBindShorthand": true,
47 | "vue.inlayHints.optionsWrapper": true,
48 | "vue.inlayHints.destructuredProps": true,
49 | "sonarlint.output.showAnalyzerLogs": true,
50 | "sonarlint.output.showVerboseLogs": true,
51 | "sonarlint.connectedMode.project": {
52 | "connectionId": "jellyfin-vue",
53 | "projectKey": "jellyfin_jellyfin-vue"
54 | },
55 | "typescript.tsserver.experimental.enableProjectDiagnostics": true
56 | }
57 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
10 |
11 |
12 | Jellyfin Vue
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/frontend/locales/fil.json:
--------------------------------------------------------------------------------
1 | {
2 | "NoMediaSourcesAvailable": "Walang available na media source",
3 | "actor": "Aktor",
4 | "actors": "Mga Aktor",
5 | "addNewPerson": "Magdagdag ng bagong tao",
6 | "airPlayDevices": "Mga AirPlay Device",
7 | "albums": "Mga Album",
8 | "allLanguages": "Lahat ng mga wika",
9 | "artist": "Artista",
10 | "artists": "Mga artista",
11 | "audio": "Audio",
12 | "badRequest": "Bad request. Subukan muli",
13 | "books": "Mga libro",
14 | "byArtist": "Ayon sa {artist}",
15 | "cancel": "Kanselahin",
16 | "castAndCrew": "Cast at crew",
17 | "collections": "Mga koleksyon",
18 | "communityRating": "Rating ng komunidad",
19 | "confirm": "Kumpirmahin",
20 | "connect": "Kumonekta",
21 | "continueWatching": "Ituloy ang panonood",
22 | "criticRating": "Rating ng kritiko",
23 | "customRating": "Custom na rating",
24 | "dateAdded": "Idinagdag ng petsa",
25 | "details": "Mga Detalye",
26 | "directing": "Nagdidirekta",
27 | "director": "Direktor",
28 | "disabled": "Naka-disable",
29 | "discNumber": "Disc {discNumber}",
30 | "editMetadata": "I-edit ang metadata",
31 | "editPerson": "I-edit ang tao",
32 | "endsAt": "Matatapos sa {time}",
33 | "favorite": "Paborito",
34 | "features": "Mga Feature",
35 | "filter": "I-filter",
36 | "filtersNotFound": "Hindi ma-load ang mga filter",
37 | "fullScreen": "I-fullscreen",
38 | "general": "General",
39 | "genericJellyfinPlaceholderDevice": "Generic na Jellyfin device",
40 | "genres": "Mga genre",
41 | "home": "Home",
42 | "images": "Mga larawan",
43 | "incorrectUsernameOrPassword": "Maling username o password",
44 | "jellyfinLogo": "Logo ng jellyfin",
45 | "latestLibrary": "Pinakabagong {libraryName}",
46 | "libraries": "Mga Library",
47 | "libraryEmpty": "Walang laman ang library na ito",
48 | "libraryEmptyFilters": "Walang tugma. Alisin ang ilang mga filter upang magpakita ng higit pang mga item",
49 | "liked": "Nagustuhan"
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/locales/lt.json:
--------------------------------------------------------------------------------
1 | {
2 | "accept": "Priimti",
3 | "actor": "Aktorius/ė",
4 | "actors": "Aktoriai",
5 | "addNewPerson": "Pridėti naują asmenį",
6 | "administrator": "Administratorius/ė",
7 | "airPlayDevices": "AirPlay įrenginiai",
8 | "albums": "Albumai",
9 | "allLanguages": "Visos kalbos",
10 | "audio": "Garsas",
11 | "badRequest": "Bloga užklausa. Bandykite iš naujo",
12 | "continueWatching": "Tęsti žiūrėjimą",
13 | "endsAt": "Baigiasi {time}",
14 | "home": "Pradžia",
15 | "incorrectUsernameOrPassword": "Neteisingas vartotojo vardas arba slaptažodis",
16 | "libraryEmpty": "Tuščia mediateka",
17 | "logout": "Atsijungti",
18 | "password": "Slaptažodis",
19 | "play": "Leisti",
20 | "present": "Dabar",
21 | "rating": "Įvertinimas",
22 | "releaseDate": "Išleidimo data",
23 | "signIn": "Prisijungti",
24 | "unexpectedError": "Netikėta klaida",
25 | "username": "Vartotojo vardas"
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/locales/mn.json:
--------------------------------------------------------------------------------
1 | {
2 | "artist": "Жүжигчин",
3 | "cancel": "Цуцлах",
4 | "collections": "Багц",
5 | "criticRating": "Зэрэглэл",
6 | "dateAdded": "Өдөр нэмэх",
7 | "disabled": "Идэвхгүй",
8 | "favorite": "Дуртай",
9 | "filter": "Шүүлтүүр",
10 | "general": "Ерөнхий",
11 | "genres": "Төрөл зүйл",
12 | "movies": "Кино",
13 | "name": "Нэр",
14 | "nextUp": "Дараах",
15 | "people": "Хүмүүс",
16 | "play": "Тоглуулах",
17 | "rating": "Зэрэглэл",
18 | "series": "Цуврал",
19 | "songs": "Дуунууд",
20 | "trailer": "Трейлер",
21 | "version": "Хувилбар",
22 | "video": "Видео"
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/locales/nn.json:
--------------------------------------------------------------------------------
1 | {
2 | "NoMediaSourcesAvailable": "Ingen mediekjelder tilgjengelege",
3 | "actor": "Skodespelar",
4 | "actors": "Skodespelarar",
5 | "addNewPerson": "Legg til ny person",
6 | "albums": "Album",
7 | "allLanguages": "Alle språk",
8 | "artist": "Artist",
9 | "artists": "Artistar",
10 | "audio": "Lyd",
11 | "badRequest": "Feil i førespurnaden. Prøv igjen",
12 | "byArtist": "Av {artist}",
13 | "cancel": "Avbryt",
14 | "castAndCrew": "Rollebesetning og mannskap",
15 | "collections": "Samlingar",
16 | "communityRating": "Vurderinga til fellesskapet",
17 | "confirm": "Bekreft",
18 | "connect": "Kople til",
19 | "continueWatching": "Fortset å sjå",
20 | "criticRating": "Kritikarvurdering",
21 | "customRating": "Eigendefinert vurdering",
22 | "dateAdded": "Dato lagt til",
23 | "details": "Detaljar",
24 | "directing": "Regi",
25 | "director": "Regissør",
26 | "discNumber": "Plate {discNumber}",
27 | "editMetadata": "Rediger metadata",
28 | "editPerson": "Rediger person",
29 | "endsAt": "Sluttar {time}",
30 | "favorite": "Favoritt",
31 | "filter": "Filter",
32 | "filtersNotFound": "Kan ikkje lasta filter",
33 | "fullScreen": "Fullskjerm",
34 | "general": "Generelt",
35 | "genres": "Sjangrar",
36 | "guestStar": "Gjesterolle",
37 | "home": "Heim",
38 | "images": "Bilete",
39 | "incorrectUsernameOrPassword": "Feil brukarnamn eller passord",
40 | "jellyfinLogo": "Jellyfin-logo",
41 | "latestLibrary": "Nyaste i {libraryName}",
42 | "libraries": "Bibliotek",
43 | "libraryEmpty": "Dette biblioteket er tomt",
44 | "libraryEmptyFilters": "Ingen treff. Fjern nokre filter for å visa fleire element",
45 | "liked": "Likt",
46 | "logout": "Logg ut",
47 | "manualLogin": "Manuell innlogging",
48 | "menu": "Meny",
49 | "metadataEditor": "Metadataredigerer",
50 | "moreLikeArtist": "Meir liknande {artist}",
51 | "movies": "Filmar",
52 | "name": "Namn",
53 | "networks": "Nettverk",
54 | "nextUp": "Neste",
55 | "noImagesFound": "Ingen bilete funne",
56 | "noResultsFound": "Her var det tomt",
57 | "numberTracks": "{number} spor",
58 | "originalTitle": "Original tittel",
59 | "overview": "Oversyn",
60 | "parentalRating": "Aldersgrense",
61 | "parentalRatings": "Aldersgrense",
62 | "password": "Passord",
63 | "people": "Personar",
64 | "person": "Person",
65 | "play": "Spel av",
66 | "playFromBeginning": "Spel av frå starten"
67 | }
68 |
--------------------------------------------------------------------------------
/frontend/locales/pa.json:
--------------------------------------------------------------------------------
1 | {
2 | "albums": "ਐਲਬਮਾਂ",
3 | "artists": "ਕਲਾਕਾਰ",
4 | "collections": "ਸੰਗ੍ਰਹਿ",
5 | "genres": "ਸ਼ੈਲੀਆਂ",
6 | "movies": "ਫਿਲਮਾਂ",
7 | "shows": "ਸ਼ੋਅਜ਼",
8 | "undefined": "ਪਰਿਭਾਸ਼ਤ"
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/locales/sr-Latn.json:
--------------------------------------------------------------------------------
1 | {
2 | "NoMediaSourcesAvailable": "Nema dosutpnih izvora za medije",
3 | "actor": "Glumac",
4 | "actors": "Glumci",
5 | "addNewPerson": "Dodaj novu osobu",
6 | "airPlayDevices": "AirPlay uređaji",
7 | "albums": "Albumi",
8 | "allLanguages": "Svi jezici",
9 | "artist": "Umetnik",
10 | "artists": "Umetnici",
11 | "audio": "Zvuk",
12 | "badRequest": "Loš zahtev. Probajte ponovo",
13 | "books": "Knjige",
14 | "byArtist": "Od strane {artist}",
15 | "cancel": "Otkaži",
16 | "castAndCrew": "Glumci i ekipa",
17 | "collections": "Kolekcije",
18 | "communityRating": "Ocena zajednice",
19 | "confirm": "Potvrdi",
20 | "connect": "Poveži se",
21 | "continueWatching": "Nastavi sa gledanjem",
22 | "criticRating": "Ocena kritičara",
23 | "customRating": "Prilagođena ocena",
24 | "dateAdded": "Dodat na datum",
25 | "details": "Detalji",
26 | "directing": "Režija",
27 | "director": "Režiser",
28 | "disabled": "Onemogućeno",
29 | "discNumber": "Disk {discNumber}",
30 | "editMetadata": "Uredi metapodatke",
31 | "editPerson": "Uredi osobu",
32 | "endsAt": "Završava se u {time}",
33 | "favorite": "Omiljeno",
34 | "features": "Karakteristike",
35 | "filter": "Filter",
36 | "filtersNotFound": "Nije moguće učitati filtere",
37 | "fullScreen": "ceo ekran",
38 | "general": "Generalno",
39 | "genericJellyfinPlaceholderDevice": "Opšti Jellyfin uređaj",
40 | "genres": "Žanrovi",
41 | "googleCastPlaceholderDevice": "Google Cast uređaj",
42 | "guestStar": "Gostujuća zvezda",
43 | "home": "Početna strana",
44 | "images": "Slike",
45 | "incorrectUsernameOrPassword": "Pogrešno korisničko ime ili šifra",
46 | "jellyfinLogo": "logo Jellyfin-a",
47 | "latestLibrary": "Najnovije {libraryName}",
48 | "libraries": "Biblioteke",
49 | "libraryEmpty": "Ova biblioteka je prazna",
50 | "libraryEmptyFilters": "Nema podudaranja. Izbrišite neke filtere da bi se videlo više stavki",
51 | "liked": "Sviđano",
52 | "logout": "Odjavi se",
53 | "manualLogin": "Ručno prijavljivanje",
54 | "menu": "Meni",
55 | "metadataEditor": "Uređivač metapodataka",
56 | "moreLikeArtist": "Više kao {artist}",
57 | "movies": "Filmovi",
58 | "name": "Ime",
59 | "networks": "Mreže",
60 | "nextUp": "Sledeće",
61 | "noImagesFound": "Slike nisu pronađene",
62 | "noInformationAvailable": "Nema dostupnih informacija",
63 | "noResultsFound": "Ovde nema ničega",
64 | "numberTracks": "{number} traka",
65 | "originalTitle": "Originalni naslov",
66 | "overview": "Pregled",
67 | "parentalRating": "Roditeljska ocena",
68 | "parentalRatings": "Roditeljske ocene",
69 | "password": "Šifra",
70 | "people": "Ljudi",
71 | "person": "Osoba",
72 | "play": "Pusti",
73 | "playFromBeginning": "Pusti od početka"
74 | }
75 |
--------------------------------------------------------------------------------
/frontend/public/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultServerURLs": [],
3 | "allowServerSelection": true,
4 | "routerMode": "hash"
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/patrickm53/jellyfin-vue/57607f1adc5b0b22780e53cadd55cce115b97a54/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
56 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/frontend/scripts/paths.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path';
2 |
3 | export const localeFilesFolder = resolve('locales/**');
4 | export const srcRoot = `${resolve('src')}/`;
5 | export const entrypoints = {
6 | index: resolve('index.html'),
7 | main: `${srcRoot}/main.ts`,
8 | splashscreen: `${srcRoot}/splashscreen.ts`
9 | };
10 |
--------------------------------------------------------------------------------
/frontend/scripts/plugins/analysis.ts:
--------------------------------------------------------------------------------
1 | import { visualizer } from 'rollup-plugin-visualizer';
2 | import type { RollupLog } from 'rollup';
3 | import type { Plugin } from 'vite';
4 |
5 | /**
6 | * This plugin extracts the logic for the analyze commands, so the main Vite config is cleaner.
7 | */
8 | export function JellyfinVueAnalysis(): Plugin {
9 | const warnings: RollupLog[] = [];
10 |
11 | return {
12 | name: 'Jellyfin_Vue:analysis',
13 | enforce: 'post',
14 | config: (_, env) => {
15 | if (env.mode === 'analyze:bundle') {
16 | return {
17 | build: {
18 | rollupOptions: {
19 | plugins: [
20 | visualizer({
21 | open: true,
22 | filename: 'dist/stats.html'
23 | })
24 | ]
25 | }
26 | }
27 | };
28 | } else if (env.mode === 'analyze:cycles') {
29 | return {
30 | build: {
31 | rollupOptions: {
32 | onwarn: (warning) => {
33 | if (warning.code === 'CIRCULAR_DEPENDENCY') {
34 | warnings.push(warning);
35 | }
36 | }
37 | }
38 | }
39 | };
40 | }
41 | },
42 | closeBundle: () => {
43 | if (warnings.length > 0) {
44 | for (const warning of warnings) {
45 | console.warn(warning);
46 | }
47 |
48 | throw new Error('There are circular dependencies');
49 | }
50 | }
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/scripts/plugins/chunking.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from 'vite';
2 |
3 | /**
4 | * Creates the Rollup's chunking strategy of the application (for code-splitting)
5 | */
6 | export function JellyfinVueChunking(): Plugin {
7 | return {
8 | name: 'Jellyfin_Vue:chunking',
9 | config: () => ({
10 | build: {
11 | rollupOptions: {
12 | output: {
13 | /**
14 | * This is the first thing that should be debugged when there are issues
15 | * withe the bundle. Check these issues:
16 | * - https://github.com/vitejs/vite/issues/5142
17 | * - https://github.com/evanw/esbuild/issues/399
18 | * - https://github.com/rollup/rollup/issues/3888
19 | */
20 | manualChunks(id) {
21 | const match = /node_modules\/([^/]+)/.exec(id)?.[1];
22 |
23 | if (id.includes('virtual:locales') || ((id.includes('vuetify') || id.includes('date-fns')) && id.includes('locale'))) {
24 | return 'localization/meta';
25 | }
26 |
27 | if (id.includes('@intlify/unplugin-vue-i18n/messages')
28 | ) {
29 | return 'localization/messages';
30 | }
31 |
32 | /**
33 | * Split each vendor in its own chunk
34 | */
35 | if (match) {
36 | return `vendor/${match.replace('@', '')}`;
37 | }
38 | }
39 | }
40 | }
41 | }
42 | })
43 | };
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/scripts/virtual-modules.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * We need to match our locales to the date-fns and Vuetify ones for proper localization.
3 | * In order to reduce bundle size, we calculate here (at build time) only the locales that we
4 | * have defined in the "locales" folder, to include only those, instead of importing all of them.
5 | *
6 | * We expose them later as 'virtual:date-fns/locales' and 'virtual:vuetify/locales' using @rollup/plugin-virtual
7 | */
8 | import { readdirSync } from 'node:fs';
9 | import { localeFilesFolder } from './paths';
10 |
11 | const localeFiles = readdirSync(localeFilesFolder.replace('**', ''));
12 | const localeNames = localeFiles.map(l => l.replace('.json', ''));
13 |
14 | /**
15 | * Normalizes the locale names from the JSON files to ESM-compatible exports
16 | */
17 | function localeTransform(keys: string[], l: string): string | undefined {
18 | const testStrings = l.split('-');
19 | const lang = testStrings.join('');
20 |
21 | /**
22 | * - If the i18n locale exactly matches the one from the module
23 | * - Removes the potential dash to match for instance "en-US" from i18n to "enUS" for the module.
24 | * We also need to remove all the hyphens, as using named exports with them is not valid JS syntax
25 | */
26 | if (keys.includes(l) || keys.includes(lang)) {
27 | return lang;
28 | /**
29 | * Takes the part before the potential hyphen to try, for instance "fr-FR" in i18n to "fr"
30 | */
31 | } else if (keys.includes(testStrings[0])) {
32 | return `${testStrings[0]} as ${lang}`;
33 | }
34 | }
35 |
36 | /**
37 | * Date-fns locale parsing
38 | */
39 | const dfnskeys = Object.keys(await import('date-fns/locale'));
40 | /**
41 | * We need this due to the differences between the vue i18n and date-fns locales.
42 | */
43 | const dfnsExports = localeNames
44 | .map(l => localeTransform(dfnskeys, l))
45 | .filter((l): l is string => typeof l === 'string');
46 |
47 | /**
48 | * Vuetify locale parsing
49 | */
50 | const vuetify = await import('vuetify/locale');
51 | const vuetifyKeys = Object.keys(vuetify);
52 | const vuetifyExports = localeNames
53 | .map(l => localeTransform(vuetifyKeys, l))
54 | .filter((l): l is string => typeof l === 'string');
55 |
56 | /**
57 | * Get commit hash
58 | */
59 | const commit_available = !Number(process.env.IS_STABLE) && Boolean(process.env.COMMIT_HASH);
60 | const commit_hash = commit_available && `'${process.env.COMMIT_HASH}'` || undefined;
61 |
62 | /**
63 | * Date-fns exports all english locales with variants, so we need to add the match manually
64 | */
65 | dfnsExports.unshift('enUS as en');
66 |
67 | export default {
68 | 'virtual:locales/date-fns': `export { ${dfnsExports.join(
69 | ', '
70 | )} } from 'date-fns/locale'`,
71 | 'virtual:locales/vuetify': `export { ${vuetifyExports.join(
72 | ', '
73 | )} } from 'vuetify/locale'`,
74 | 'virtual:commit': `export const commit_hash = ${commit_hash}`
75 | };
76 |
--------------------------------------------------------------------------------
/frontend/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
9 |
10 |
13 |
16 |
17 |
20 |
21 |
22 |
23 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
77 |
--------------------------------------------------------------------------------
/frontend/src/assets/styles/global.css:
--------------------------------------------------------------------------------
1 | * {
2 | font-family: var(--j-font-family), sans-serif, system-ui !important;
3 | }
4 |
5 | html {
6 | overscroll-behavior: none;
7 | color-scheme: light dark;
8 | }
9 |
10 | html.no-forced-scrollbar {
11 | overflow-y: unset !important;
12 | }
13 |
14 | /* Custom utility classes */
15 |
16 | .absolute-cover {
17 | height: 100%;
18 | width: 100%;
19 | position: absolute;
20 | top: 0;
21 | left: 0;
22 | right: 0;
23 | bottom: 0;
24 | }
25 |
26 | .link {
27 | cursor: pointer;
28 | color: inherit !important;
29 | text-decoration: none;
30 | }
31 |
32 | .link:hover {
33 | text-decoration: underline;
34 | }
35 |
36 | .pa-s {
37 | padding: env(safe-area-inset-top) env(safe-area-inset-right)
38 | env(safe-area-inset-bottom) env(safe-area-inset-left);
39 | }
40 |
41 | .pt-s {
42 | padding-top: env(safe-area-inset-top);
43 | }
44 |
45 | .pl-s {
46 | padding-left: env(safe-area-inset-left);
47 | }
48 |
49 | .pr-s {
50 | padding-right: env(safe-area-inset-right);
51 | }
52 |
53 | .pb-s {
54 | padding-bottom: env(safe-area-inset-bottom);
55 | }
56 |
57 | .text-capitalize-first-letter::first-letter {
58 | text-transform: uppercase;
59 | }
60 |
--------------------------------------------------------------------------------
/frontend/src/assets/styles/index.css:
--------------------------------------------------------------------------------
1 | @import url('@fontsource-variable/figtree');
2 | @import url('@unocss/reset/tailwind-compat.css');
3 | @import url('./global.css');
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/styles/splashscreen.css:
--------------------------------------------------------------------------------
1 | .j-splash {
2 | height: 100dvh;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | flex-direction: column;
7 | background-color: var(--j-color-background);
8 | }
9 |
10 | .j-splash > img {
11 | min-width: 30%;
12 | min-height: 30%;
13 | width: 30%;
14 | height: 30%;
15 | object-fit: contain;
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/LikeButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
39 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/MarkPlayedButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
44 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/Playback/NextTrackButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
16 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/Playback/PlayPauseButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
12 |
13 |
30 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/Playback/PreviousTrackButton.vue:
--------------------------------------------------------------------------------
1 |
2 | playbackManager.setPreviousItem()"
6 | @dblclick="() => playbackManager.setPreviousItem(true)">
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/Playback/RepeatButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
12 |
13 |
25 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/Playback/ShuffleButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
16 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/ScrollToTopButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
31 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/SortButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{ !$vuetify.display.smAndDown ? sortingLabel : undefined }}
15 |
16 |
17 |
19 |
21 |
22 |
23 |
24 |
29 |
30 |
31 |
32 |
33 |
66 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/SubtitleSelectionButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
16 |
21 |
22 |
34 |
35 |
36 |
37 |
38 |
39 |
63 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/TypeButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 | {{
6 | $vuetify.display.smAndDown || items.length === 0
7 | ? undefined
8 | : innerModel.length === 0
9 | ? items[0].title
10 | : items.find((i) => i.value == innerModel[0])?.title
11 | }}
12 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
22 |
23 |
24 |
70 |
--------------------------------------------------------------------------------
/frontend/src/components/Dialogs/GenericDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
12 |
13 |
14 |
17 |
18 |
19 |
20 |
23 |
24 |
25 |
26 |
27 | {{ title }}
28 |
29 |
30 |
31 |
34 | {{ subtitle }}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
68 |
--------------------------------------------------------------------------------
/frontend/src/components/Forms/AddServerForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
14 |
17 |
20 |
26 | {{ $t('changeServer') }}
27 |
28 |
29 |
30 |
38 | {{ $t('connect') }}
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
82 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/Card/ServerCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 | {{ serverInfo.ServerName }}
8 |
9 | {{ serverInfo.PublicAddress }}
10 |
11 |
12 |
13 |
14 |
17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
28 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
74 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/CollectionTabs.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
11 | {{ type }} ({{ baseItems?.length ?? '' }})
12 |
13 |
14 |
17 | {{ $t('collectionEmpty') }}
18 |
19 |
22 |
26 |
27 |
30 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
49 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/Identify/IdentifyResults.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
11 |
12 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | {{ item.Name }}
23 |
24 |
25 | {{ getSubtitle(item) }}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
66 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/MediaDetail/MediaDetailAttr.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ `${name}: ` }}
4 |
5 | {{ value }}
6 |
7 |
10 |
11 |
12 |
13 |
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/MediaInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{
5 | $t('seasonEpisodeAbbrev', {
6 | seasonNumber: item.ParentIndexNumber,
7 | episodeNumber: item.IndexNumber
8 | })
9 | }}
10 |
11 | {{ item.ProductionYear }}
12 | {{ item.OfficialRating }}
13 |
14 |
17 |
18 |
19 | {{ item.CommunityRating.toFixed(1) }}
20 |
21 |
22 | {{ $t('numberTracks', { number: item.ChildCount }) }}
23 |
24 |
25 | {{ getRuntimeTime(item.RunTimeTicks) }}
26 |
27 |
28 | {{ getEndsAtTime(item.RunTimeTicks) }}
29 |
30 |
31 |
32 |
33 |
46 |
47 |
65 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/MediaSourceSelector.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 | {{ i.value.Name }}
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
48 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/Metadata/DateInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
37 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/Metadata/MetadataEditorDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
13 |
14 |
26 |
27 |
32 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/PeopleList.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
24 |
25 |
27 |
28 |
29 |
30 |
31 |
40 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/RelatedItems.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
10 |
11 | {{ t('youMayAlsoLike') }}
12 |
13 |
14 |
17 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
49 |
50 |
66 |
--------------------------------------------------------------------------------
/frontend/src/components/Item/WatchedIndicator.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/AppBar/AppBarButtonLayout.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/AppBar/Buttons/CastButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{ $t('syncPlayGroups') }}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {{ $t('airPlayDevices') }}
32 |
33 |
34 |
35 |
36 |
37 | {{ $t('googleCastPlaceholderDevice') }}
38 |
39 |
40 |
41 |
42 |
43 | {{ $t('genericJellyfinPlaceholderDevice') }}
44 |
45 |
46 |
47 |
48 |
49 |
50 | {{ $t('remoteDevices') }}
51 |
52 |
53 |
54 |
55 |
60 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/AppBar/SearchField.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
46 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Artist/ArtistTab.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
13 |
16 |
17 |
18 |
19 | {{ release.ProductionYear }}
20 |
21 |
25 |
28 | {{ release.Name }}
29 |
30 |
31 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
53 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Backdrop.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
15 |
16 |
17 |
18 |
19 |
53 |
54 |
58 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Carousel/Item/ItemsCarouselTitle.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
15 |
16 |
21 | {{ titleString }}
22 |
23 |
27 | {{ subtitle }}
28 |
29 |
33 | {{ item.Taglines[0] }}
34 |
35 |
36 |
37 |
38 |
84 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Images/Blurhash/BlurhashCanvas.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
52 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Images/Blurhash/BlurhashImage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
16 |
19 |
20 |
23 |
24 |
25 |
26 |
27 |
28 |
53 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Images/Blurhash/BlurhashImageIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
24 |
25 |
31 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Images/UserImage.vue:
--------------------------------------------------------------------------------
1 |
2 |
4 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
49 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/ItemCols.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Navigation/CommitLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
18 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/Navigation/NavigationDrawer.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
20 | {{ $t('libraries') }}
21 |
22 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
73 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/SettingsPage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
35 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/TimeSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 | {{ formatTime(playbackManager.currentTime) }}
12 |
13 |
14 | {{ formatTime(sliderValue) }}
15 |
16 |
17 | {{ formatTime(runtime) }}
18 |
19 |
20 |
21 |
22 |
48 |
--------------------------------------------------------------------------------
/frontend/src/components/Layout/VolumeSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
15 |
16 | {{ Math.round(sliderValue) }}
17 |
18 |
19 |
20 |
21 |
22 |
59 |
60 |
65 |
--------------------------------------------------------------------------------
/frontend/src/components/Playback/MiniVideoPlayer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
14 |
19 |
20 |
21 |
24 |
25 |
26 |
27 |
28 |
29 |
32 |
33 |
34 |
35 |
36 |
37 |
39 |
43 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 |
55 |
56 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
76 |
77 |
89 |
--------------------------------------------------------------------------------
/frontend/src/components/Playback/MusicVisualizer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
45 |
--------------------------------------------------------------------------------
/frontend/src/components/Skeletons/SkeletonCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
12 |
13 |
14 |
23 |
24 |
52 |
--------------------------------------------------------------------------------
/frontend/src/components/Skeletons/SkeletonItemGrid.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
11 |
12 |
13 |
14 |
15 |
27 |
28 |
49 |
--------------------------------------------------------------------------------
/frontend/src/components/System/AboutLinks.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {{ linkItem.name }}
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
65 |
--------------------------------------------------------------------------------
/frontend/src/components/System/AddApiKey.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 | {{ t('addApiKey') }}
9 |
10 |
13 |
17 |
22 | {{ $t('confirm') }}
23 |
24 |
25 | {{ $t('cancel') }}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
94 |
95 |
100 |
--------------------------------------------------------------------------------
/frontend/src/components/System/LoadingIndicator.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/frontend/src/components/System/LocaleSwitcher.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
13 |
14 |
15 |
18 |
19 |
25 |
26 |
27 |
28 |
29 |
30 |
43 |
44 |
49 |
--------------------------------------------------------------------------------
/frontend/src/components/System/Snackbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 | {{ state.message }}
8 |
9 |
10 |
11 |
33 |
34 |
47 |
--------------------------------------------------------------------------------
/frontend/src/components/Users/UserCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
22 |
23 |
24 |
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Wizard/WizardRemoteAccess.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
10 |
15 | {{ t('previous') }}
16 |
17 |
22 | {{ t('finish') }}
23 |
24 |
25 |
26 |
27 |
72 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JApp.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
9 | :root {
10 |
11 | cursor: wait;
12 |
13 | --j-color-background: rgb(var(--v-theme-background));
14 | --j-font-family: '{{ typography }}';
15 | }
16 |
17 |
18 |
19 |
20 |
21 |
22 |
43 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JHover.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
7 |
8 |
9 |
10 |
15 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JSafeHtml.vue:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JSlot.vue:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JSplashscreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |

10 |
13 |
14 |
15 |
18 | {{ $t('logout') }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JView.vue:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
9 |
10 |
11 |
21 |
--------------------------------------------------------------------------------
/frontend/src/components/lib/JVirtual/j-virtual.worker.ts:
--------------------------------------------------------------------------------
1 | import { expose } from 'comlink';
2 | import { getItemOffsetByIndex, type ResizeMeasurement, type BufferMeta, type InternalItem } from './pipeline';
3 | import { sealed } from '@/utils/validation';
4 |
5 | @sealed
6 | class JVirtualWorker {
7 | /**
8 | * Gets the items that must be visible in the grid based on the buffer measurements
9 | */
10 | public readonly getVisibleIndexes = (
11 | bufferMeta: BufferMeta,
12 | resizeMeasurement: ResizeMeasurement,
13 | collectionLength: number
14 | ): InternalItem[] => {
15 | const { bufferedOffset, bufferedLength } = bufferMeta;
16 |
17 | /**
18 | * When approaching the end of the VirtualGrid, we want to always be sure that
19 | * bufferedLength = the amount of visible items,
20 | * so no DOM nodes are destroyed (which would be a waste of resources if the user reverses the scroll),
21 | * so we need to change the slice values depending on the current offset.
22 | *
23 | * OffsetPlusLength is the length ahead that we have DOM nodes available for rendering.
24 | *
25 | * We initialize 'first' to 0 and 'last' to collectionLength to take into account those cases
26 | * where the available buffer is bigger than the real amount of items we need to display,
27 | * the if statement is where we really take into account a real virtual scrolling scenario
28 | */
29 | const offsetPlusLength = bufferedOffset + bufferedLength;
30 | let first = 0;
31 | let last = collectionLength;
32 |
33 | if (collectionLength > bufferedLength) {
34 | first
35 | = collectionLength < offsetPlusLength
36 | ? collectionLength - bufferedLength
37 | : bufferedOffset;
38 | last
39 | = collectionLength < offsetPlusLength ? collectionLength : offsetPlusLength;
40 | }
41 |
42 | const res = [];
43 |
44 | for (let index = first; index < last; index++) {
45 | const { x, y } = getItemOffsetByIndex(index, resizeMeasurement);
46 | const translateX = `translateX(${x}px)`;
47 | const translateY = `translateY(${y}px)`;
48 |
49 | res.push({
50 | index,
51 | style: {
52 | transform: `${translateX} ${translateY}`
53 | }
54 | });
55 | }
56 |
57 | return res;
58 | };
59 | }
60 |
61 | const instance = new JVirtualWorker();
62 | export default instance;
63 | export type IJVirtualWorker = typeof instance;
64 |
65 | expose(instance);
66 |
--------------------------------------------------------------------------------
/frontend/src/composables/backdrop.ts:
--------------------------------------------------------------------------------
1 | import { ImageType, type BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
2 | import { toValue, type MaybeRefOrGetter } from 'vue';
3 | import { useBackdrop } from '@/components/Layout/Backdrop.vue';
4 | import { getBlurhash } from '@/utils/images';
5 |
6 | /**
7 | * Same as useBackdrop, but is a shorthand for items only.
8 | */
9 | export function useItemBackdrop(item: MaybeRefOrGetter>, ...args: Tail>) {
10 | useBackdrop(() => getBlurhash(toValue(item) ?? {}, ImageType.Primary), ...args);
11 | }
12 |
13 | /**
14 | * == COMPONENT COMPOSABLE ==
15 | *
16 | * The definition of the composable are in the relevant component,
17 | * so the code that tracks the state of the component are alongside the component itself.
18 | *
19 | * We could re-define it here, but we would lose access to the
20 | * JSDoc of the original: that's why we just re-export it again.
21 | */
22 | export { useBackdrop } from '@/components/Layout/Backdrop.vue';
23 |
--------------------------------------------------------------------------------
/frontend/src/composables/page-title.ts:
--------------------------------------------------------------------------------
1 | import { computed, onBeforeUnmount, onMounted, shallowRef, toRef, toValue, type MaybeRefOrGetter } from 'vue';
2 | import { useTitle as _useTitle, watchImmediate } from '@vueuse/core';
3 | import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
4 | import { isNil } from '@/utils/validation';
5 |
6 | /**
7 | * This composable handles the page title of the application.
8 | */
9 | const DEFAULT_PAGE_TITLE = 'Jellyfin Vue';
10 | const _title = shallowRef();
11 | const _fullTitle = computed(() => _title.value ? `${_title.value.trim()} | ${DEFAULT_PAGE_TITLE}` : DEFAULT_PAGE_TITLE);
12 |
13 | _useTitle(_fullTitle);
14 |
15 | /**
16 | * Reactively sets the page title with the following template: **`title` | Jellyfin Vue**. Can be used in 2 ways:
17 | *
18 | * 1. Providing raw/reactive/ref/getter argument that will be tracked for changes by the composable. Raw values
19 | * can't be tracked directly, so they need to be wrapped in a ref or getter.
20 | * 2. Accessing the returned ref and setting it manually.
21 | *
22 | * Value will be set to default (undefined) when the component consuming this composable is unmounted.
23 | */
24 | export function usePageTitle(title?: MaybeRefOrGetter>) {
25 | onMounted(() => {
26 | if (!isNil(title)) {
27 | watchImmediate(toRef(title), val => _title.value = val ?? undefined);
28 | }
29 | });
30 |
31 | onBeforeUnmount(() => _title.value = undefined);
32 |
33 | return { title: _title };
34 | };
35 |
36 | /**
37 | * Same as useTitle, but is a shorthand for items only.
38 | */
39 | export function useItemPageTitle(item: MaybeRefOrGetter>) {
40 | usePageTitle(() => toValue(item)?.Name);
41 | };
42 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-confirm-dialog.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * == COMPONENT COMPOSABLE ==
3 | *
4 | * The definition of the composable are in the relevant component,
5 | * so the code that tracks the state of the component are alongside the component itself.
6 | *
7 | * We could re-define it here, but we would lose access to the
8 | * JSDoc of the original: that's why we just re-export it again.
9 | */
10 | export { useConfirmDialog } from '@/components/Dialogs/ConfirmDialog.vue';
11 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-datefns.ts:
--------------------------------------------------------------------------------
1 | import * as datefnslocales from 'virtual:locales/date-fns';
2 | import { i18n } from '@/plugins/i18n';
3 | import { isObj } from '@/utils/validation';
4 |
5 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */
6 |
7 | /**
8 | * Use any date fns function with proper localization, based on the current locale.
9 | * Pass the date-fns function to invoke as the first parameter,
10 | * and it's parameters as the second parameter
11 | *
12 | * THIS FUNCTION MUST BE CALLED INSIDE A COMPUTED PROPERTY OR A TEMPLATE FOR CHANGES TO THE CURRENT LOCALE TO BE REFLECTED
13 | *
14 | * @param func - date-fns function to invoke
15 | * @param params - Parameters to pass to the date-fns function
16 | */
17 | export function useDateFns any>(
18 | func: T,
19 | ...params: Parameters
20 | ): ReturnType {
21 | /**
22 | * We need to remove the hyphen of our locale codes, as using named exports with them is not valid JS syntax
23 | */
24 | const importCode = i18n.locale.value.replace(
25 | '-',
26 | ''
27 | ) as keyof typeof datefnslocales;
28 |
29 | if (isObj(params.at(-1)) && !(params.at(-1) instanceof Date)) {
30 | params.at(-1).locale = datefnslocales[importCode];
31 | } else {
32 | params.push({ locale: datefnslocales[importCode] });
33 | }
34 |
35 | return func(...params);
36 | }
37 |
38 | /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */
39 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-loading.ts:
--------------------------------------------------------------------------------
1 | import { computed, shallowRef, type ComputedRef } from 'vue';
2 |
3 | const requests = shallowRef(0);
4 | const isLoading = computed(() => requests.value > 0);
5 |
6 | /**
7 | * Composable for triggering the linear progress that appears at the top of the page
8 | * That progress bar is always indeterminate, so you can just start or stop requests
9 | *
10 | * For long running tasks (library refresh, config sync), use taskManager instead.
11 | * This is only meant for data fetch/push
12 | */
13 | export function useLoading(): {
14 | start: () => number;
15 | finish: () => void;
16 | isLoading: ComputedRef;
17 | } {
18 | const start = (): number => requests.value++;
19 | const finish = (): void => {
20 | if (requests.value > 0) {
21 | requests.value--;
22 | }
23 | };
24 |
25 | return { start, finish, isLoading };
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-pausable-effect.ts:
--------------------------------------------------------------------------------
1 | import { getCurrentScope, toRef, watch, type MaybeRefOrGetter } from 'vue';
2 |
3 | /**
4 | * When the passed argument is truthy, the effect scope of the current component will be paused
5 | * until the argument becomes falsy again.
6 | *
7 | * This is useful for components that need to pause the DOM patching
8 | */
9 | export function usePausableEffect(signal: MaybeRefOrGetter) {
10 | const scope = getCurrentScope();
11 |
12 | if (scope) {
13 | watch(toRef(signal), val => val ? scope.pause() : scope.resume(), { immediate: true, flush: 'sync' });
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-playback.ts:
--------------------------------------------------------------------------------
1 | import { useFullscreen, useMagicKeys, whenever } from '@vueuse/core';
2 | import { watch } from 'vue';
3 | import { router } from '@/plugins/router';
4 | import { mediaElementRef } from '@/store';
5 | import { playbackManager } from '@/store/playback-manager';
6 |
7 | interface PlaybackComposableReturn {
8 | fullscreen: ReturnType;
9 | }
10 |
11 | /**
12 | * Watchers and handlers that are common to music and video playback
13 | */
14 | export function usePlayback(): PlaybackComposableReturn {
15 | watch(() => playbackManager.currentItem, () => {
16 | if (!playbackManager.currentItem) {
17 | router.back();
18 | }
19 | });
20 |
21 | /**
22 | * - iOS's Safari fullscreen API is only available for the video element
23 | */
24 | const fullscreen = useFullscreen().isSupported.value
25 | ? useFullscreen(undefined, { autoExit: true })
26 | : useFullscreen(mediaElementRef, { autoExit: true });
27 |
28 | const keys = useMagicKeys();
29 |
30 | whenever(keys.space, playbackManager.playPause);
31 | whenever(keys.k, playbackManager.playPause);
32 | whenever(keys.right, playbackManager.skipForward);
33 | whenever(keys.l, playbackManager.skipForward);
34 | whenever(keys.left, playbackManager.skipBackward);
35 | whenever(keys.j, playbackManager.skipBackward);
36 | whenever(keys.f, fullscreen.toggle);
37 | whenever(keys.m, playbackManager.toggleMute);
38 |
39 | whenever(keys.MediaPause, playbackManager.pause);
40 | whenever(keys.Pause, playbackManager.pause);
41 | whenever(keys.MediaPlay, playbackManager.unpause);
42 | whenever(keys.MediaPlayPause, playbackManager.playPause);
43 | whenever(keys.MediaStop, playbackManager.stop);
44 | whenever(keys.Exit, playbackManager.stop);
45 | whenever(keys.MediaTrackNext, playbackManager.setNextItem);
46 | whenever(keys.MediaTrackPrevious, playbackManager.setPreviousItem);
47 | whenever(keys.MediaFastForward, playbackManager.skipForward);
48 | whenever(keys.MediaRewind, playbackManager.skipBackward);
49 | whenever(keys.AudioVolumeMute, playbackManager.toggleMute);
50 | whenever(keys.AudioVolumeUp, playbackManager.volumeUp);
51 | whenever(keys.AudioVolumeDown, playbackManager.volumeDown);
52 |
53 | return { fullscreen };
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-responsive-classes.ts:
--------------------------------------------------------------------------------
1 | import { vuetify } from '@/plugins/vuetify';
2 |
3 | const display = vuetify.display;
4 |
5 | /**
6 | * Returns an additional class based on current Vuetify breakpoint.
7 | * Possibilities:
8 | * * lg, lg-and-down, lg-and-up
9 | * * md, md-and-down, md-and-up
10 | * * sm, sm-and-down, sm-and-up
11 | * * xl, xl-and-down, xl-and-up
12 | * * xs
13 | * * xxl
14 | * * mobile
15 | *
16 | * Additionally to all the classes stated above, a mobile class is also added
17 | * when the mobile breakpoint is active.
18 | */
19 | export function useResponsiveClasses(classes: string): string {
20 | let out = classes;
21 |
22 | if (display.lg.value) {
23 | out += ' lg';
24 | }
25 |
26 | if (display.lgAndDown.value) {
27 | out += ' lg-and-down';
28 | }
29 |
30 | if (display.lgAndUp.value) {
31 | out += ' lg-and-up';
32 | }
33 |
34 | if (display.md.value) {
35 | out += ' md';
36 | }
37 |
38 | if (display.mdAndDown.value) {
39 | out += ' md-and-down';
40 | }
41 |
42 | if (display.mdAndUp.value) {
43 | out += ' md-and-up';
44 | }
45 |
46 | if (display.sm.value) {
47 | out += ' sm';
48 | }
49 |
50 | if (display.smAndDown.value) {
51 | out += ' sm-and-down';
52 | }
53 |
54 | if (display.smAndUp.value) {
55 | out += ' sm-and-up';
56 | }
57 |
58 | if (display.xl.value) {
59 | out += ' xl';
60 | }
61 |
62 | if (display.xlAndDown.value) {
63 | out += ' xl-and-down';
64 | }
65 |
66 | if (display.xlAndUp.value) {
67 | out += ' xl-and-up';
68 | }
69 |
70 | if (display.xs.value) {
71 | out += ' xs';
72 | }
73 |
74 | if (display.xxl.value) {
75 | out += ' xxl';
76 | }
77 |
78 | if (display.mobile.value) {
79 | out += ' mobile';
80 | }
81 |
82 | return out;
83 | }
84 |
--------------------------------------------------------------------------------
/frontend/src/composables/use-snackbar.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * == COMPONENT COMPOSABLE ==
3 | *
4 | * The definition of the composable are in the relevant component,
5 | * so the code that tracks the sate of the component are alongside the component itself.
6 | *
7 | * We could re-define it here, but we would lose access to the
8 | * JSDoc of the original: that's why we just re-export it again.
9 | */
10 | export { useSnackbar } from '@/components/System/Snackbar.vue';
11 |
--------------------------------------------------------------------------------
/frontend/src/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
18 |
53 |
--------------------------------------------------------------------------------
/frontend/src/layouts/fullpage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
--------------------------------------------------------------------------------
/frontend/src/layouts/server.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
9 |
12 |
13 |
14 |
15 |
17 |
--------------------------------------------------------------------------------
/frontend/src/main.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Top-level await requires ES2022 (at least) as target and module
3 | * for TypeScript compiler (check tsconfig.json)
4 | * https://caniuse.com/mdn-javascript_operators_await_top_level
5 | */
6 | import { createApp } from 'vue';
7 | import { routes } from 'vue-router/auto-routes';
8 | import { getFontFaces } from '@/utils/data-manipulation';
9 | import Root from '@/App.vue';
10 | import { hideDirective } from '@/plugins/directives';
11 | import { vuePlugin as i18n } from '@/plugins/i18n';
12 | import { createPlugin as createRemote } from '@/plugins/remote';
13 | import { router } from '@/plugins/router';
14 | import { vuetify } from '@/plugins/vuetify';
15 | /**
16 | * - GLOBAL STYLES -
17 | */
18 | import 'uno.css';
19 | import 'virtual:unocss-devtools';
20 | import '@/assets/styles/index.css';
21 |
22 | /**
23 | * - VUE PLUGINS, STORE AND DIRECTIVE -
24 | * The order of statements IS IMPORTANT
25 | */
26 | const remote = createRemote();
27 | const app = createApp(Root);
28 |
29 | /**
30 | * We add routes at this point instead of in the router plugin to avoid circular references
31 | * in components. At this stage, we're sure plugins are instantiated.
32 | */
33 | for (const route of routes) {
34 | router.addRoute(route);
35 | }
36 |
37 | app.use(remote);
38 | app.use(i18n);
39 | app.use(router);
40 | app.use(vuetify);
41 | app.directive('hide', hideDirective);
42 |
43 | /**
44 | * Ensure everything is fully loaded before mounting the app
45 | */
46 | await Promise.all([
47 | router.isReady(),
48 | ...getFontFaces().map(font => font.load())
49 | ]);
50 | await document.fonts.ready;
51 |
52 | /**
53 | * MOUNTING POINT
54 | */
55 | app.mount(document.body);
56 |
--------------------------------------------------------------------------------
/frontend/src/pages/metadata.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
25 |
97 |
112 |
--------------------------------------------------------------------------------
/frontend/src/pages/server/add.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 | {{ $t('addServer') }}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | meta:
21 | layout:
22 | name: server
23 |
24 |
25 |
33 |
--------------------------------------------------------------------------------
/frontend/src/pages/server/select.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
10 |
11 | {{ $t('selectServer') }}
12 |
13 |
14 |
19 |
20 |
27 | {{ $t('addServer') }}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | meta:
36 | layout:
37 | name: server
38 |
39 |
40 |
49 |
--------------------------------------------------------------------------------
/frontend/src/pages/settings/subtitles.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ $t('subtitles') }}
5 |
6 |
7 |
8 |
11 |
14 |
18 |
19 |
26 |
27 |
34 |
35 |
39 |
40 |
44 |
45 |
48 |
49 |
50 |
51 |
52 |
53 |
56 |
--------------------------------------------------------------------------------
/frontend/src/pages/settings/users/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ t('users') }}
5 |
6 |
7 |
12 | {{ t('newUser') }}
13 |
14 |
19 | {{ t('help') }}
20 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
35 |
36 |
37 |
38 | {{ user.Name }}
39 |
40 |
43 | {{
44 | $t('lastActivityDate', {
45 | value: useDateFns(formatDistanceToNow, new Date(user.LastActivityDate), { addSuffix: true })
46 | })
47 | }}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | meta:
60 | admin: true
61 |
62 |
63 |
74 |
75 |
85 |
--------------------------------------------------------------------------------
/frontend/src/plugins/directives.ts:
--------------------------------------------------------------------------------
1 | import type { DirectiveBinding } from 'vue';
2 |
3 | /**
4 | * Toggles the CSS 'visibility' property of an element.
5 | */
6 | export function hideDirective(
7 | element: HTMLElement,
8 | binding: DirectiveBinding
9 | ): void {
10 | if (element) {
11 | element.style.visibility = binding.value ? 'hidden' : 'visible';
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/plugins/i18n.ts:
--------------------------------------------------------------------------------
1 | import { createI18n } from 'vue-i18n';
2 | import messages from '@intlify/unplugin-vue-i18n/messages';
3 |
4 | /**
5 | * See @/store/clientSettings to check where the current user language is initialised
6 | */
7 |
8 | const DEFAULT_LANGUAGE = 'en';
9 |
10 | export const vuePlugin = createI18n({
11 | fallbackLocale: DEFAULT_LANGUAGE,
12 | globalInjection: true,
13 | legacy: false,
14 | messages: messages
15 | });
16 |
17 | export const i18n = vuePlugin.global;
18 |
--------------------------------------------------------------------------------
/frontend/src/plugins/remote/axios.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Instantiates the Axios instance used for the SDK and requests
3 | */
4 | import axios, {
5 | type AxiosError
6 | } from 'axios';
7 | import auth from './auth';
8 | import { useSnackbar } from '@/composables/use-snackbar';
9 | import { i18n } from '@/plugins/i18n';
10 | import { sealed } from '@/utils/validation';
11 |
12 | @sealed
13 | class RemotePluginAxios {
14 | public readonly instance = axios.create();
15 | private readonly _defaults = this.instance.defaults;
16 |
17 | public resetDefaults(): void {
18 | this.instance.defaults = this._defaults;
19 | }
20 |
21 | /**
22 | * Intercepts 401 (Unathorized) error code and logs out the user inmmediately,
23 | * as the session probably has been revoked remotely.
24 | */
25 | public logoutInterceptor = async (error: AxiosError): Promise => {
26 | if (
27 | error.response?.status === 401
28 | && auth.currentUser
29 | && !error.config?.url?.includes('/Sessions/Logout')
30 | && !error.config?.url?.includes('/Users/Me')
31 | ) {
32 | try {
33 | await auth.refreshCurrentUserInfo();
34 | } catch {
35 | await auth.logoutCurrentUser(true);
36 | useSnackbar(i18n.t('kickedOut'), 'error');
37 | }
38 | }
39 |
40 | /**
41 | * Pass the error so it's handled in try/catch blocks afterwards
42 | */
43 | throw error;
44 | };
45 |
46 | public constructor() {
47 | this.instance.interceptors.response.use(
48 | undefined,
49 | this.logoutInterceptor
50 | );
51 | }
52 | }
53 |
54 | const RemotePluginAxiosInstance = new RemotePluginAxios();
55 |
56 | export default RemotePluginAxiosInstance;
57 |
--------------------------------------------------------------------------------
/frontend/src/plugins/remote/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The 'remote' plugin includes the tools needed to interact with a Jellyfin
3 | * server:
4 | * - Authentication store ($remote.auth)
5 | * - Axios as the request performer ($remote.axios)
6 | * - Jellyfin SDK ($remote.sdk)
7 | * - WebSocket ($remote.socket)
8 | */
9 | import type { App } from 'vue';
10 | import RemotePluginAuthInstance from './auth';
11 | import RemotePluginSDKInstance from './sdk';
12 | import RemotePluginSocketInstance from './socket';
13 | import { isNil, sealed } from '@/utils/validation';
14 | import { getJSONConfig } from '@/utils/external-config';
15 |
16 | @sealed
17 | class RemotePlugin {
18 | public readonly auth = RemotePluginAuthInstance;
19 | public readonly sdk = RemotePluginSDKInstance;
20 | public readonly socket = RemotePluginSocketInstance;
21 | }
22 |
23 | export const remote = new RemotePlugin();
24 |
25 | /**
26 | * Installs the remote plugin into the Vue instance to enable the usage of
27 | * $remote to access all the tools for handling a Jellyfin server connection.
28 | */
29 | export function createPlugin(): {
30 | install: (app: App) => Promise;
31 | } {
32 | return {
33 | install: async (app: App): Promise => {
34 | /**
35 | * `remote` is readonly but this is the one place it should actually be set
36 | */
37 | (app.config.globalProperties.$remote as typeof remote)
38 | = remote;
39 |
40 | const auth = remote.auth;
41 | const config = await getJSONConfig();
42 | const defaultServers = config.defaultServerURLs;
43 | /**
44 | * We reverse the list so the first server is the last to be connected,
45 | * and thus is the chosen one by default
46 | */
47 | const missingServers = defaultServers
48 | .filter((serverUrl) => {
49 | const server = auth.servers.find(
50 | lsServer => lsServer.PublicAddress === serverUrl
51 | );
52 |
53 | return isNil(server);
54 | }).reverse();
55 |
56 | for (const serverUrl of missingServers) {
57 | await auth.connectServer(serverUrl, true);
58 | }
59 | }
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/src/plugins/remote/sdk/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This plugin instantiates the Jellyfin SDK.
3 | * It also sets the header and base URL for our axios instance
4 | */
5 | import type { Api } from '@jellyfin/sdk';
6 | import { watchSyncEffect } from 'vue';
7 | import RemotePluginAuthInstance from '../auth';
8 | import RemotePluginAxiosInstance from '../axios';
9 | import SDK, { useOneTimeAPI } from './sdk-utils';
10 | import { isNil, sealed } from '@/utils/validation';
11 |
12 | @sealed
13 | class RemotePluginSDK {
14 | private readonly sdk = SDK;
15 | public readonly clientInfo = this.sdk.clientInfo;
16 | public readonly deviceInfo = this.sdk.deviceInfo;
17 | public readonly discovery = this.sdk.discovery;
18 | public api: Api | undefined;
19 |
20 | public constructor(auth: typeof RemotePluginAuthInstance) {
21 | /**
22 | * Configure app's axios instance to perform requests to the given Jellyfin server.
23 | */
24 | watchSyncEffect(() => {
25 | const server = auth.currentServer;
26 | const accessToken = auth.currentUserToken;
27 |
28 | if (isNil(server)) {
29 | RemotePluginAxiosInstance.resetDefaults();
30 | this.api = undefined;
31 | } else {
32 | this.api = this.sdk.createApi(
33 | server.PublicAddress,
34 | accessToken,
35 | RemotePluginAxiosInstance.instance
36 | );
37 | RemotePluginAxiosInstance.instance.defaults.baseURL
38 | = server.PublicAddress;
39 | }
40 | });
41 | }
42 |
43 | public oneTimeSetup = useOneTimeAPI;
44 | /**
45 | * Generates a Jellyfin API type with the current API instance.
46 | *
47 | * USE WITH CAUTION. Make sure this is only used in places where an user is logged in
48 | */
49 | public newUserApi(apiSec: (api: Api) => T): T {
50 | // We want to explicitly assume the user is already logged in here
51 | return apiSec(this.api!);
52 | }
53 | }
54 |
55 | const RemotePluginSDKInstance = new RemotePluginSDK(RemotePluginAuthInstance);
56 |
57 | export default RemotePluginSDKInstance;
58 |
--------------------------------------------------------------------------------
/frontend/src/plugins/remote/sdk/sdk-utils.ts:
--------------------------------------------------------------------------------
1 | import { type Api, Jellyfin } from '@jellyfin/sdk';
2 | import { v4 } from 'uuid';
3 | import {
4 | isAndroid,
5 | isApple,
6 | isChrome,
7 | isChromiumBased,
8 | isEdge,
9 | isFirefox,
10 | isMobile,
11 | isTizen,
12 | isWebOS
13 | } from '@/utils/browser-detection';
14 | import { version } from '@/../package.json';
15 |
16 | /**
17 | * Returns the device ID, creating it in case it does not exist
18 | */
19 | function ensureDeviceId(): string {
20 | const storageKey = 'deviceId';
21 | const val = window.localStorage.getItem(storageKey);
22 |
23 | if (!val) {
24 | const id = v4();
25 |
26 | window.localStorage.setItem(storageKey, id);
27 |
28 | return id;
29 | }
30 |
31 | return val;
32 | }
33 |
34 | const SDK = new Jellyfin({
35 | clientInfo: {
36 | name: 'Jellyfin Web (Vue)',
37 | version: version
38 | },
39 | deviceInfo: {
40 | name: getDeviceName(),
41 | id: ensureDeviceId()
42 | }
43 | });
44 |
45 | /**
46 | * Gets the device's name based on the browser's user agent.
47 | */
48 | function getDeviceName(): string {
49 | let deviceName = 'Unknown';
50 |
51 | if (isChrome()) {
52 | deviceName = 'Chrome';
53 | } else if (isEdge() && !isChromiumBased()) {
54 | deviceName = 'Edge (EdgeHTML)';
55 | } else if (isEdge()) {
56 | deviceName = 'Edge (Chromium)';
57 | } else if (isFirefox()) {
58 | deviceName = 'Firefox';
59 | } else if (isApple() && !isMobile()) {
60 | deviceName = 'Safari';
61 | } else if (isWebOS()) {
62 | deviceName = 'LG Smart TV';
63 | } else if (isTizen()) {
64 | deviceName = 'Samsung Smart TV';
65 | } else if (isApple() && isMobile()) {
66 | deviceName = 'iPhone';
67 | } else if (isAndroid()) {
68 | deviceName = 'Android';
69 | }
70 |
71 | return deviceName;
72 | }
73 |
74 | /**
75 | * Connects to the given server with the given credentials without
76 | * altering the app's API/SDK or axios instance.
77 | */
78 | export function useOneTimeAPI(
79 | ...arguments_: Parameters
80 | ): Api {
81 | return SDK.createApi(...arguments_);
82 | }
83 |
84 | export default SDK;
85 |
--------------------------------------------------------------------------------
/frontend/src/plugins/remote/types.d.ts:
--------------------------------------------------------------------------------
1 | import type RemotePluginAuthInstance from './auth';
2 | import type RemotePluginSDKInstance from './sdk';
3 | import type RemotePluginSocketInstance from './socket';
4 |
5 | export interface RemotePlugin {
6 | sdk: typeof RemotePluginSDKInstance;
7 | auth: typeof RemotePluginAuthInstance;
8 | socket: typeof RemotePluginSocketInstance;
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/plugins/router/middlewares/admin-pages.ts:
--------------------------------------------------------------------------------
1 | import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router';
2 | import { useSnackbar } from '@/composables/use-snackbar';
3 | import { i18n } from '@/plugins/i18n';
4 | import { remote } from '@/plugins/remote';
5 |
6 | /**
7 | * Redirect the user to index page when attempting to access
8 | * an admin page in settings.
9 | */
10 | export function adminGuard(
11 | to: RouteLocationNormalized
12 | ): boolean | RouteLocationRaw {
13 | if (to.meta.admin && !remote.auth.currentUser?.Policy?.IsAdministrator) {
14 | useSnackbar(i18n.t('unauthorized'), 'error');
15 |
16 | return { path: '/', replace: true };
17 | }
18 |
19 | return true;
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/plugins/router/middlewares/login.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | RouteLocationNormalized,
3 | RouteLocationPathRaw,
4 | RouteLocationRaw
5 | } from 'vue-router';
6 | import { until } from '@vueuse/core';
7 | import { remote } from '@/plugins/remote';
8 | import { isNil } from '@/utils/validation';
9 | import { getJSONConfig } from '@/utils/external-config';
10 |
11 | const serverAddUrl = '/server/add';
12 | const serverSelectUrl = '/server/select';
13 | const serverLoginUrl = '/server/login';
14 | const serverRoutes = new Set([serverAddUrl, serverSelectUrl]);
15 | const routes = new Set([...serverRoutes, serverLoginUrl]);
16 |
17 | /**
18 | * Performs the login guard redirection ensuring no redirection loops happen
19 | */
20 | function doRedir(dest: RouteLocationPathRaw, to: RouteLocationNormalized) {
21 | return to.path === dest.path
22 | ? true
23 | : dest;
24 | }
25 |
26 | /**
27 | * Redirects to login page if there's no user logged in.
28 | */
29 | export async function loginGuard(
30 | to: RouteLocationNormalized
31 | ): Promise {
32 | const jsonConfig = await getJSONConfig();
33 |
34 | if (jsonConfig.defaultServerURLs.length && isNil(remote.auth.currentServer)) {
35 | await until(() => remote.auth.currentServer).toBeTruthy({ flush: 'pre' });
36 | }
37 |
38 | if (
39 | (
40 | !jsonConfig.allowServerSelection
41 | && serverRoutes.has(to.path)
42 | )
43 | || (
44 | !isNil(remote.auth.currentServer)
45 | && !isNil(remote.auth.currentUser)
46 | && !isNil(remote.auth.currentUserToken)
47 | && routes.has(to.path)
48 | )
49 | ) {
50 | return doRedir({ path: '/', replace: true }, to);
51 | }
52 |
53 | if (!remote.auth.servers.length) {
54 | return doRedir({ path: serverAddUrl, replace: true }, to);
55 | } else if (isNil(remote.auth.currentServer)) {
56 | return doRedir({ path: serverSelectUrl, replace: true }, to);
57 | } else if (isNil(remote.auth.currentUser)) {
58 | return doRedir({ path: serverLoginUrl, replace: true }, to);
59 | }
60 |
61 | return true;
62 | }
63 |
--------------------------------------------------------------------------------
/frontend/src/plugins/router/middlewares/meta.ts:
--------------------------------------------------------------------------------
1 | import { defu } from 'defu';
2 | import { ref, toRaw } from 'vue';
3 | import type {
4 | RouteLocationNormalized,
5 | RouteLocationRaw,
6 | RouteMeta
7 | } from 'vue-router';
8 |
9 | const defaultMeta: RouteMeta = {
10 | layout: {
11 | transition: {}
12 | }
13 | };
14 |
15 | const reactiveMeta = ref(structuredClone(defaultMeta));
16 |
17 | /**
18 | * This middleware handles the meta property between routes
19 | *
20 | * The layout of the destination page needs to exist before the route
21 | * is accessed. This is why we need the following block in pages:
22 | *
23 | *
24 | * meta:
25 | * layout:
26 | * name: server
27 | *
28 | *
29 | * That block is also needed when a property needs to be resolved before
30 | * the component is instantiated (i.e, the admin property, so the correct redirection is done)
31 | *
32 | * That populates the meta property through Vite at build time, so the router
33 | * instantiation looks like this: https://router.vuejs.org/guide/advanced/meta.html#route-meta-fields
34 | *
35 | * If we want to change the meta at runtime (for page title, backdrop, etc), we need to merge
36 | * whatever default value is defined in the route block with our custom properties. In order
37 | * to ensure consistency, we pass an object with defaults that matches the *RouteMeta* type
38 | * present at the plugins.d.ts file
39 | */
40 | export function metaGuard(
41 | to: RouteLocationNormalized,
42 | from: RouteLocationNormalized
43 | ): boolean | RouteLocationRaw {
44 | reactiveMeta.value = defu(to.meta, structuredClone(defaultMeta));
45 | /**
46 | * This is needed to ensure all the meta matches the expected data
47 | */
48 | from.meta = defu(toRaw(from.meta), structuredClone(defaultMeta));
49 | to.meta = reactiveMeta.value;
50 |
51 | if (from.meta.layout.transition.leave) {
52 | to.meta.layout.transition.enter = from.meta.layout.transition.leave;
53 | }
54 |
55 | return true;
56 | }
57 |
--------------------------------------------------------------------------------
/frontend/src/plugins/router/middlewares/playback.ts:
--------------------------------------------------------------------------------
1 | import type { RouteLocationRaw } from 'vue-router';
2 | import { playbackManager } from '@/store/playback-manager';
3 | import { isNil } from '@/utils/validation';
4 |
5 | /**
6 | * Validates that no playback is happening when accesing a route
7 | */
8 | export function playbackGuard(): RouteLocationRaw | boolean {
9 | if (isNil(playbackManager.currentItem)) {
10 | return { path: '/', replace: true };
11 | }
12 |
13 | return true;
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/plugins/router/middlewares/validate.ts:
--------------------------------------------------------------------------------
1 | import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router';
2 | import { useSnackbar } from '@/composables/use-snackbar';
3 | import { i18n } from '@/plugins/i18n';
4 | import { isStr } from '@/utils/validation';
5 |
6 | /**
7 | * Validates that the route has a correct itemId parameter by checking that the parameter is a valid
8 | * MD5 hash.
9 | */
10 | export function validateGuard(
11 | to: RouteLocationNormalized
12 | ): boolean | RouteLocationRaw {
13 | if (('itemId' in to.params) && isStr(to.params.itemId)) {
14 | const check = /[\da-f]{32}/i.test(to.params.itemId);
15 |
16 | if (!check) {
17 | useSnackbar(i18n.t('routeValidationError'), 'error');
18 |
19 | return { path: '/', replace: true };
20 | }
21 | }
22 |
23 | return true;
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/plugins/vuetify.ts:
--------------------------------------------------------------------------------
1 | import * as vuetifyLocales from 'virtual:locales/vuetify';
2 | import { createVuetify, type ThemeDefinition } from 'vuetify';
3 | import { md3 } from 'vuetify/blueprints';
4 | import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
5 | import 'vuetify/styles';
6 |
7 | const dark: ThemeDefinition = {
8 | colors: {
9 | accent: '#FF4081',
10 | background: '#111827',
11 | card: '#1c2331',
12 | chip: '#4b5563',
13 | divider: '#374151',
14 | error: '#FF5252',
15 | info: '#0099CC',
16 | menu: '#374151',
17 | surface: '#1f2937',
18 | primary: '#9d37c2',
19 | secondary: '#2f3951',
20 | success: '#4CAF50',
21 | thumb: '#252e41',
22 | warning: '#FB8C00'
23 | },
24 | dark: true
25 | };
26 |
27 | const light: ThemeDefinition = {
28 | colors: {
29 | accent: '#FF4081',
30 | background: '#f2f2f2',
31 | card: '#FFFFFF',
32 | error: '#FF5252',
33 | info: '#33b5e5',
34 | primary: '#9d37c2',
35 | secondary: '#424242',
36 | success: '#4CAF50',
37 | thumb: '#000000',
38 | warning: '#FB8C00',
39 | chip: '#e4e4e4',
40 | surface: '#f5f5f5'
41 | },
42 | dark: false
43 | };
44 |
45 | /**
46 | * If we don't define custom theme, Vuetify is going to take the
47 | * client's preferred color schema.
48 | */
49 | export const vuetify = createVuetify({
50 | blueprint: md3,
51 | defaults: {
52 | VSelect: {
53 | variant: 'outlined'
54 | },
55 | VTextField: {
56 | variant: 'outlined',
57 | color: 'primary'
58 | },
59 | VCheckbox: {
60 | color: 'primary'
61 | },
62 | VProgressLinear: {
63 | color: 'primary'
64 | },
65 | VBtn: {
66 | color: undefined,
67 | variant: 'text'
68 | },
69 | VTooltip: {
70 | activator: 'parent'
71 | },
72 | VMenu: {
73 | activator: 'parent'
74 | },
75 | VChip: {
76 | rounded: true
77 | }
78 | },
79 | locale: {
80 | fallback: 'en',
81 | messages: vuetifyLocales
82 | },
83 | theme: {
84 | themes: {
85 | dark,
86 | light
87 | }
88 | },
89 | icons: {
90 | defaultSet: 'mdi',
91 | aliases,
92 | sets: {
93 | mdi
94 | }
95 | }
96 | });
97 |
--------------------------------------------------------------------------------
/frontend/src/plugins/workers/blurhash-decoder.worker.ts:
--------------------------------------------------------------------------------
1 | import { decode } from 'blurhash';
2 | import { expose } from 'comlink';
3 | import { sealed } from '@/utils/validation';
4 |
5 | /**
6 | * Decodes blurhash strings into pixels
7 | */
8 | @sealed
9 | class BlurhashDecoder {
10 | private readonly _pixelsCache = new Map();
11 |
12 | /**
13 | * Decodes blurhash outside the main thread, in a web worker.
14 | *
15 | * @param hash - Hash to decode.
16 | * @param width - Width of the decoded pixel array
17 | * @param height - Height of the decoded pixel array.
18 | * @param punch - Contrast of the decoded pixels
19 | * @returns - Returns the decoded pixels in the proxied response by Comlink
20 | */
21 | public readonly getPixels = (
22 | hash: string,
23 | width: number,
24 | height: number,
25 | punch: number
26 | ): Uint8ClampedArray => {
27 | try {
28 | const params = String([hash, width, height, punch]);
29 | let pixels = this._pixelsCache.get(params);
30 |
31 | if (!pixels) {
32 | pixels = decode(hash, width, height, punch);
33 | this._pixelsCache.set(params, pixels);
34 | }
35 |
36 | return pixels;
37 | } catch {
38 | throw new TypeError(`Blurhash ${hash} is not valid`);
39 | }
40 | };
41 |
42 | /**
43 | * Clear the blurhashes cache
44 | */
45 | public readonly clearCache = (): void => {
46 | this._pixelsCache.clear();
47 | };
48 | }
49 |
50 | const instance = new BlurhashDecoder();
51 | export default instance;
52 | export type IBlurhashDecoder = typeof instance;
53 |
54 | expose(instance);
55 |
--------------------------------------------------------------------------------
/frontend/src/plugins/workers/canvas-drawer.worker.ts:
--------------------------------------------------------------------------------
1 | import { expose } from 'comlink';
2 | import { sealed } from '@/utils/validation';
3 |
4 | /**
5 | * Draws canvases offscreen
6 | */
7 | @sealed
8 | class CanvasDrawer {
9 | /**
10 | * Draws a transferred canvas from the main thread
11 | *
12 | * @param canvas - Canvas to draw the decoded pixels. Must come from main thread's canvas.transferControlToOffscreen()
13 | * @param width - Width of the target imageData
14 | * @param height - Height of the target imageData
15 | */
16 | public readonly drawBlurhash = ({
17 | canvas, pixels, width, height
18 | }: { canvas: OffscreenCanvas; pixels: Uint8ClampedArray; width: number; height: number }) => {
19 | const ctx = canvas.getContext('2d');
20 | const imageData = ctx!.createImageData(width, height);
21 |
22 | imageData.data.set(pixels);
23 | ctx!.putImageData(imageData, 0, 0);
24 | };
25 | }
26 |
27 | const instance = new CanvasDrawer();
28 | export default instance;
29 | export type ICanvasDrawer = typeof instance;
30 |
31 | expose(instance);
32 |
--------------------------------------------------------------------------------
/frontend/src/plugins/workers/generic.worker.ts:
--------------------------------------------------------------------------------
1 | import { expose } from 'comlink';
2 | import { parseSsaFile, parseVttFile } from './generic/subtitles';
3 | import { sealed } from '@/utils/validation';
4 |
5 | /**
6 | * All functions that could take some time to complete and block the main thread
7 | * must be offloaded to this worker
8 | */
9 | @sealed
10 | class GenericWorker {
11 | /**
12 | * Shuffles an array using the Durstenfeld shuffle algorithm, an
13 | * optimized version of Fisher-Yates shuffle.
14 | */
15 | public shuffle(array: T[]) {
16 | for (let i = array.length - 1; i > 0; i--) {
17 | const j = Math.floor(Math.random() * (i + 1));
18 |
19 | [array[i], array[j]] = [array[j], array[i]];
20 | }
21 |
22 | return array;
23 | };
24 |
25 | /**
26 | * Functions for parsing subtitles
27 | */
28 | public parseVttFile = parseVttFile;
29 | public parseSsaFile = parseSsaFile;
30 | }
31 |
32 | const instance = new GenericWorker();
33 | export default instance;
34 | export type IGenericWorker = typeof instance;
35 |
36 | expose(instance);
37 |
--------------------------------------------------------------------------------
/frontend/src/plugins/workers/index.ts:
--------------------------------------------------------------------------------
1 | import { wrap } from 'comlink';
2 | import type { IBlurhashDecoder } from './blurhash-decoder.worker';
3 | import BlurhashDecoder from './blurhash-decoder.worker?worker';
4 | import type { ICanvasDrawer } from './canvas-drawer.worker';
5 | import CanvasDrawer from './canvas-drawer.worker?worker';
6 | import type { IGenericWorker } from './generic.worker';
7 | import GenericWorker from './generic.worker?worker';
8 |
9 | /**
10 | * A worker for decoding blurhash strings into pixels
11 | */
12 | export const blurhashDecoder = wrap(new BlurhashDecoder());
13 |
14 | /**
15 | * A worker for drawing canvas offscreen. The canvas must be transferred like this:
16 | * ```ts
17 | * import { transfer } from 'comlink';
18 | *
19 | * await canvasDrawer.drawBlurhash(transfer(
20 | * { canvas: offscreen,
21 | * pixels,
22 | * width,
23 | * height
24 | * }, [offscreen]));
25 | * ```
26 | */
27 | export const canvasDrawer = wrap(new CanvasDrawer());
28 |
29 | /**
30 | * A worker for running any non-specific function that could be expensive and take some time to complete,
31 | * blocking the main thread
32 | */
33 | export const genericWorker = wrap(new GenericWorker());
34 |
--------------------------------------------------------------------------------
/frontend/src/splashscreen.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This modules handles the splashscreen color scheme based on user-agent preferences or stored settings
3 | * before Vue is loaded.
4 | *
5 | * When Vue is loaded, it gets replaced by the main application and the JSplashscreen (used in App.vue)
6 | * is displayed instead.
7 | */
8 | import { destr } from 'destr';
9 | import type { ClientSettingsState } from '@/store/client-settings';
10 | import { isBool } from '@/utils/validation';
11 | import '@/assets/styles/splashscreen.css';
12 |
13 | const store = localStorage.getItem('clientSettings') ?? '{}';
14 | const parsedStore = destr(store);
15 | const matchedDarkColorScheme = window.matchMedia(
16 | '(prefers-color-scheme: dark)'
17 | ).matches;
18 | const darkColor = '#111827';
19 | const lightColor = '#f2f2f2';
20 | let colorToApply: typeof darkColor | typeof lightColor = matchedDarkColorScheme ? darkColor : lightColor;
21 |
22 | if ('darkMode' in parsedStore) {
23 | const storeDarkMode = parsedStore.darkMode;
24 |
25 | if (isBool(storeDarkMode)) {
26 | colorToApply = parsedStore.darkMode === true ? darkColor : lightColor;
27 | }
28 | }
29 |
30 | document.body.style.setProperty('--j-color-background', colorToApply);
31 |
--------------------------------------------------------------------------------
/frontend/src/store/client-settings/subtitle-settings.ts:
--------------------------------------------------------------------------------
1 | import { watch } from 'vue';
2 | import { remote } from '@/plugins/remote';
3 | import { sealed } from '@/utils/validation';
4 | import { SyncedStore } from '@/store/super/synced-store';
5 | import type { TypographyChoices } from '@/store';
6 |
7 | /**
8 | * == INTERFACES AND TYPES ==
9 | */
10 |
11 | export interface SubtitleSettingsState {
12 | /**
13 | * Whether the customization of the subtitles is enabled or not
14 | * @default: false
15 | */
16 | enabled: boolean;
17 | /**
18 | * default: Default application typography.
19 | *
20 | * system: System typography
21 | *
22 | * auto: Selects the current selected typography for the application
23 | * @default: auto
24 | */
25 | fontFamily: 'auto' | TypographyChoices;
26 | fontSize: number;
27 | positionFromBottom: number;
28 | backdrop: boolean;
29 | stroke: boolean;
30 | }
31 |
32 | @sealed
33 | class SubtitleSettingsStore extends SyncedStore {
34 | public state = this._state;
35 |
36 | public constructor() {
37 | super('subtitleSettings', {
38 | enabled: false,
39 | fontFamily: 'auto',
40 | fontSize: 1.5,
41 | positionFromBottom: 10,
42 | backdrop: true,
43 | stroke: false
44 | }, 'localStorage', [
45 | 'enabled',
46 | 'fontSize',
47 | 'positionFromBottom',
48 | 'backdrop',
49 | 'stroke'
50 | ]);
51 |
52 | /**
53 | * == WATCHERS ==
54 | */
55 | watch(
56 | () => remote.auth.currentUser,
57 | () => {
58 | if (!remote.auth.currentUser) {
59 | this._reset();
60 | }
61 | }, { flush: 'post' }
62 | );
63 | }
64 | }
65 |
66 | export const subtitleSettings = new SubtitleSettingsStore();
67 |
--------------------------------------------------------------------------------
/frontend/src/store/super/common-store.ts:
--------------------------------------------------------------------------------
1 | import { useStorage, type RemovableRef } from '@vueuse/core';
2 | import { reactive, toValue } from 'vue';
3 | import { mergeExcludingUnknown } from '@/utils/data-manipulation';
4 | import { isNil } from '@/utils/validation';
5 |
6 | export type Persistence = 'localStorage' | 'sessionStorage';
7 |
8 | export abstract class CommonStore {
9 | protected readonly _storeKey: string;
10 | private readonly _defaultState: T;
11 | private readonly _internalState: T | RemovableRef;
12 |
13 | protected get _state(): T {
14 | return toValue(this._internalState);
15 | }
16 |
17 | protected readonly _reset = (): void => {
18 | Object.assign(this._state, this._defaultState);
19 | };
20 |
21 | protected constructor(storeKey: string, defaultState: T, persistence?: Persistence) {
22 | this._storeKey = storeKey;
23 | this._defaultState = defaultState;
24 |
25 | let storage;
26 |
27 | if (persistence === 'localStorage') {
28 | storage = window.localStorage;
29 | } else if (persistence === 'sessionStorage') {
30 | storage = window.sessionStorage;
31 | }
32 |
33 | this._internalState = isNil(storage)
34 | ? reactive(structuredClone(defaultState)) as T
35 | : useStorage(storeKey, structuredClone(defaultState), storage, {
36 | mergeDefaults: (storageValue, defaults) =>
37 | mergeExcludingUnknown(storageValue, defaults)
38 | });
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/utils/data-manipulation.ts:
--------------------------------------------------------------------------------
1 | import { defu } from 'defu';
2 | import { toRaw } from 'vue';
3 | import { genericWorker } from '@/plugins/workers';
4 |
5 | /**
6 | * Merge 2 objects, excluding the keys from the destination that are not present in source
7 | *
8 | * @param object - Target object. This one contains keys that might not be present in defaults
9 | * @param defaultObject - Sample/default representation of the object that should be used to detect which keys
10 | * should/shouldn't exist in the target.
11 | */
12 | export function mergeExcludingUnknown(
13 | object: T,
14 | defaultObject: T
15 | ): T {
16 | const defaultKeys = new Set(Object.keys(defaultObject) as (keyof T)[]);
17 | const missingKeys = (Object.keys(object) as (keyof T)[]).filter(
18 | key => !defaultKeys.has(key)
19 | );
20 |
21 | object = defu(object, defaultObject);
22 |
23 | for (const key of missingKeys) {
24 | delete object[key];
25 | }
26 |
27 | return object;
28 | }
29 |
30 | /**
31 | * Uppercase the first letter of a string
32 | */
33 | export function upperFirst(str: T): Capitalize {
34 | return (str[0].toUpperCase() + str.slice(1)) as Capitalize;
35 | }
36 |
37 | /**
38 | * Get the font faces present in the document.
39 | *
40 | * Instead of using a normal iterable (like `...[...document.fonts.keys()]`),
41 | * we need this for Firefox compatibility.
42 | *
43 | * See https://github.com/jellyfin/jellyfin-vue/issues/2432
44 | */
45 | export function getFontFaces() {
46 | const iterable = document.fonts.keys();
47 | const results = [];
48 | let iterator = iterable.next();
49 |
50 | while (iterator.done === false) {
51 | results.push(iterator.value);
52 |
53 | iterator = iterable.next();
54 | }
55 |
56 | return results;
57 | }
58 |
59 | /**
60 | * Picks certain keys from an object and returns a new object with only those keys.
61 | */
62 | export function pick(object: T, keys: (keyof T)[]): Partial {
63 | const res = {} as Partial;
64 |
65 | for (const key of keys) {
66 | if (key in object) {
67 | res[key] = object[key];
68 | }
69 | }
70 |
71 | return res;
72 | }
73 |
74 | /**
75 | * Shuffles an array in a WebWorker using the Durstenfeld shuffle algorithm, an
76 | * optimized version of Fisher-Yates shuffle.
77 | *
78 | * It's also prepared for the case when the array is reactive thorugh Vue's `ref` or `reactive`.
79 | */
80 | export async function shuffle(array: T[]): Promise {
81 | return await genericWorker.shuffle(toRaw(array)) as T[];
82 | }
83 |
--------------------------------------------------------------------------------
/frontend/src/utils/external-config.ts:
--------------------------------------------------------------------------------
1 | import { isArray, isBool, isNil, isObj, isStr } from '@/utils/validation';
2 |
3 | interface ExternalJSONConfig {
4 | defaultServerURLs: string[];
5 | allowServerSelection: boolean;
6 | routerMode: 'hash' | 'history';
7 | }
8 |
9 | let externalConfig: ExternalJSONConfig | undefined;
10 |
11 | /**
12 | * Asserts that the config parameter is a valid configuration shape
13 | */
14 | function validateJsonConfig(
15 | config: unknown
16 | ): asserts config is ExternalJSONConfig {
17 | if (!isObj(config)) {
18 | throw new Error('Expected not null or defined config');
19 | }
20 |
21 | if (
22 | !('defaultServerURLs' in config)
23 | || !isArray(config.defaultServerURLs)
24 | ) {
25 | throw new Error('Expected defaultServerURLS array');
26 | }
27 |
28 | if (
29 | config.defaultServerURLs.some(
30 | defaultServerURL => !isStr(defaultServerURL)
31 | )
32 | ) {
33 | throw new Error('Expected defaultServerURLs to be a list of strings');
34 | }
35 |
36 | if (!('allowServerSelection' in config) || !isBool(config.allowServerSelection)) {
37 | throw new Error('Expected allowServerSelection to be boolean');
38 | }
39 |
40 | if (
41 | !('routerMode' in config)
42 | || !isStr(config.routerMode)
43 | || !['hash', 'history'].includes(config.routerMode)
44 | ) {
45 | throw new Error('Expected router mode to be either hash or history');
46 | }
47 | }
48 |
49 | /**
50 | * Fetch configuration at runtime from the config.json file
51 | * We use destr for serialization as it has better support for JS primitives.
52 | */
53 | export async function getJSONConfig(): Promise {
54 | if (isNil(externalConfig)) {
55 | const loadedConfig: unknown = await (
56 | await fetch('config.json', { cache: 'no-store' })
57 | ).json();
58 |
59 | validateJsonConfig(loadedConfig);
60 |
61 | externalConfig = loadedConfig;
62 | }
63 |
64 | return externalConfig;
65 | }
66 |
--------------------------------------------------------------------------------
/frontend/src/utils/forms.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Helper for form related functions
3 | *
4 | */
5 |
6 | export interface VSelectItem {
7 | value: T;
8 | }
9 |
10 | /**
11 | * Returns a suitable list for use with the 'item' prop for v-select
12 | *
13 | * @param values - list of values to use for the v-select
14 | * @returns list ready to be used in the :item property of a v-select
15 | */
16 | export function getItemizedSelect(values: T[]): VSelectItem[] {
17 | return values.map((value) => {
18 | return { value };
19 | });
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/utils/html.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Helper for HTML manipulation and sanitization
3 | */
4 | import DOMPurify from 'dompurify';
5 | import { parse } from 'marked';
6 |
7 | /**
8 | * Sanitizes a string containing HTML tags and replaces newlines with the proper HTML tag.
9 | *
10 | * @param input - string to sanitize
11 | * @returns a cleaned up string
12 | */
13 | export function sanitizeHtml(input: string, isMarkdown = false): string {
14 | // Some providers have newlines, replace them with the proper tag.
15 | const cleanString = input.replaceAll(/\r\n|\r|\n/g, '
');
16 | const inputString = isMarkdown ? parse(cleanString, { async: false }) : cleanString;
17 |
18 | return DOMPurify.sanitize(
19 | inputString,
20 | {
21 | USE_PROFILES: { html: true }
22 | }
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/utils/i18n.ts:
--------------------------------------------------------------------------------
1 | import { upperFirst } from '@/utils/data-manipulation';
2 | import { isStr } from '@/utils/validation';
3 |
4 | /**
5 | * Given a locale code, return the language name of another locale
6 | */
7 | export function getLocaleName(
8 | fromCode: string,
9 | toCode = 'en'
10 | ): string | undefined {
11 | const r = new Intl.DisplayNames([toCode], { type: 'language' }).of(fromCode);
12 |
13 | return isStr(r) ? upperFirst(r) : r;
14 | }
15 |
16 | /**
17 | * Given a locale code, return the language name in that locale
18 | */
19 | export function getLocaleNativeName(code: string): string | undefined {
20 | return getLocaleName(code, code);
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/audio-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import { isApple, isTizen, isTv, isWebOS } from '@/utils/browser-detection';
6 |
7 | /**
8 | * Determines if audio codec is supported
9 | */
10 | export function getSupportedAudioCodecs(format: string): boolean {
11 | let typeString;
12 |
13 | if (format === 'flac' && isTv()) {
14 | return true;
15 | } else if (format === 'wma' && isTizen()) {
16 | return true;
17 | } else if (format === 'asf' && isTv()) {
18 | return true;
19 | } else if (format === 'opus') {
20 | if (!isWebOS()) {
21 | typeString = 'audio/ogg; codecs="opus"';
22 |
23 | return !!document
24 | .createElement('audio')
25 | .canPlayType(typeString)
26 | .replace(/no/, '');
27 | }
28 |
29 | return false;
30 | } else if (format === 'alac' && isApple()) {
31 | return true;
32 | } else if (format === 'webma') {
33 | typeString = 'audio/webm';
34 | } else if (format === 'mp2') {
35 | typeString = 'audio/mpeg';
36 | } else {
37 | typeString = 'audio/' + format;
38 | }
39 |
40 | return !!document
41 | .createElement('audio')
42 | .canPlayType(typeString)
43 | .replace(/no/, '');
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/fmp4-audio-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import { getSupportedAudioCodecs } from './audio-formats';
6 | import {
7 | hasAacSupport,
8 | hasAc3InHlsSupport,
9 | hasAc3Support,
10 | hasEac3Support,
11 | hasMp3AudioSupport
12 | } from './mp4-audio-formats';
13 | import { isEdge } from '@/utils/browser-detection';
14 |
15 | /**
16 | * Gets an array with the supported fmp4 codecs
17 | *
18 | * @param videoTestElement - A HTML video element for testing codecs
19 | * @returns List of supported FMP4 audio codecs
20 | */
21 | export function getSupportedFmp4AudioCodecs(
22 | videoTestElement: HTMLVideoElement
23 | ): string[] {
24 | const codecs = [];
25 |
26 | if (hasAacSupport(videoTestElement)) {
27 | codecs.push('aac');
28 | }
29 |
30 | if (hasMp3AudioSupport(videoTestElement)) {
31 | codecs.push('mp3');
32 | }
33 |
34 | if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) {
35 | codecs.push('ac3');
36 |
37 | if (hasEac3Support(videoTestElement)) {
38 | codecs.push('eac3');
39 | }
40 | }
41 |
42 | if (getSupportedAudioCodecs('flac') && !isEdge()) {
43 | codecs.push('flac');
44 | }
45 |
46 | if (getSupportedAudioCodecs('alac')) {
47 | codecs.push('alac');
48 | }
49 |
50 | return codecs;
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/fmp4-video-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import { hasH264Support, hasHevcSupport } from './mp4-video-formats';
6 | import {
7 | isApple,
8 | isChrome,
9 | isEdge,
10 | isFirefox,
11 | isTizen,
12 | isWebOS
13 | } from '@/utils/browser-detection';
14 |
15 | /**
16 | * Gets an array of supported fmp4 video codecs
17 | *
18 | * @param videoTestElement - A HTML video element for testing codecs
19 | * @returns List of supported fmp4 video codecs
20 | */
21 | export function getSupportedFmp4VideoCodecs(
22 | videoTestElement: HTMLVideoElement
23 | ): string[] {
24 | const codecs = [];
25 |
26 | if (
27 | (isApple() || isEdge() || isTizen() || isWebOS())
28 | && hasHevcSupport(videoTestElement)
29 | ) {
30 | codecs.push('hevc');
31 | }
32 |
33 | if (
34 | hasH264Support(videoTestElement)
35 | && (isChrome()
36 | || isFirefox()
37 | || isApple()
38 | || isEdge()
39 | || isTizen()
40 | || isWebOS())
41 | ) {
42 | codecs.push('h264');
43 | }
44 |
45 | return codecs;
46 | }
47 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/hls-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import { hasH264Support, hasH265Support } from './mp4-video-formats';
6 | import { hasEac3Support, hasAacSupport } from './mp4-audio-formats';
7 | import { getSupportedAudioCodecs } from './audio-formats';
8 | import { isTv } from '@/utils/browser-detection';
9 |
10 | /**
11 | * Check if client supports AC3 in HLS stream
12 | *
13 | * @param videoTestElement - A HTML video element for testing codecs
14 | * @returns Determines if the browser has AC3 in HLS support
15 | */
16 | function supportsAc3InHls(
17 | videoTestElement: HTMLVideoElement
18 | ): boolean | string {
19 | if (isTv()) {
20 | return true;
21 | }
22 |
23 | if (videoTestElement.canPlayType) {
24 | return (
25 | videoTestElement
26 | .canPlayType('application/x-mpegurl; codecs="avc1.42E01E, ac-3"')
27 | .replace(/no/, '')
28 | || videoTestElement
29 | .canPlayType(
30 | 'application/vnd.apple.mpegURL; codecs="avc1.42E01E, ac-3"'
31 | )
32 | .replace(/no/, '')
33 | );
34 | }
35 |
36 | return false;
37 | }
38 |
39 | /**
40 | * Gets the supported HLS video codecs
41 | *
42 | * @param videoTestElement - A HTML video element for testing codecs
43 | * @returns Array of video codecs supported in HLS
44 | */
45 | export function getHlsVideoCodecs(
46 | videoTestElement: HTMLVideoElement
47 | ): string[] {
48 | const hlsVideoCodecs = [];
49 |
50 | if (hasH264Support(videoTestElement)) {
51 | hlsVideoCodecs.push('h264');
52 | }
53 |
54 | if (hasH265Support(videoTestElement) || isTv()) {
55 | hlsVideoCodecs.push('h265', 'hevc');
56 | }
57 |
58 | return hlsVideoCodecs;
59 | }
60 |
61 | /**
62 | * Gets the supported HLS audio codecs
63 | *
64 | * @param videoTestElement - A HTML video element for testing codecs
65 | * @returns Array of audio codecs supported in HLS
66 | */
67 | export function getHlsAudioCodecs(
68 | videoTestElement: HTMLVideoElement
69 | ): string[] {
70 | const hlsVideoAudioCodecs = [];
71 |
72 | if (supportsAc3InHls(videoTestElement)) {
73 | hlsVideoAudioCodecs.push('ac3');
74 |
75 | if (hasEac3Support(videoTestElement)) {
76 | hlsVideoAudioCodecs.push('eac3');
77 | }
78 | }
79 |
80 | if (hasAacSupport(videoTestElement)) {
81 | hlsVideoAudioCodecs.push('aac');
82 | }
83 |
84 | if (getSupportedAudioCodecs('opus')) {
85 | hlsVideoAudioCodecs.push('opus');
86 | }
87 |
88 | return hlsVideoAudioCodecs;
89 | }
90 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/transcoding-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import {
6 | isEdge,
7 | isTizen,
8 | isTv,
9 | supportsMediaSource
10 | } from '@/utils/browser-detection';
11 |
12 | /**
13 | * Checks if the client can play native HLS
14 | *
15 | * @param videoTestElement - A HTML video element for testing codecs
16 | * @returns Determines if the browser can play native Hls
17 | */
18 | export function canPlayNativeHls(videoTestElement: HTMLVideoElement): boolean {
19 | if (isTizen()) {
20 | return true;
21 | }
22 |
23 | return !!(
24 | videoTestElement.canPlayType('application/x-mpegURL').replace(/no/, '')
25 | || videoTestElement
26 | .canPlayType('application/vnd.apple.mpegURL')
27 | .replace(/no/, '')
28 | );
29 | }
30 |
31 | /**
32 | * Determines if the browser can play Hls with Media Source Extensions
33 | */
34 | export function canPlayHlsWithMSE(): boolean {
35 | return supportsMediaSource();
36 | }
37 |
38 | /**
39 | * Determines if the browser can play Mkvs
40 | */
41 | export function hasMkvSupport(videoTestElement: HTMLVideoElement): boolean {
42 | if (isTv()) {
43 | return true;
44 | }
45 |
46 | if (
47 | videoTestElement.canPlayType('video/x-matroska').replace(/no/, '')
48 | || videoTestElement.canPlayType('video/mkv').replace(/no/, '')
49 | ) {
50 | return true;
51 | }
52 |
53 | return !!isEdge();
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/ts-audio-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import {
6 | hasAacSupport,
7 | hasAc3InHlsSupport,
8 | hasAc3Support,
9 | hasEac3Support,
10 | hasMp3AudioSupport
11 | } from './mp4-audio-formats';
12 |
13 | /**
14 | * List of supported Ts audio codecs
15 | */
16 | export function getSupportedTsAudioCodecs(
17 | videoTestElement: HTMLVideoElement
18 | ): string[] {
19 | const codecs = [];
20 |
21 | if (hasAacSupport(videoTestElement)) {
22 | codecs.push('aac');
23 | }
24 |
25 | if (hasMp3AudioSupport(videoTestElement)) {
26 | codecs.push('mp3');
27 | }
28 |
29 | if (hasAc3Support(videoTestElement) && hasAc3InHlsSupport(videoTestElement)) {
30 | codecs.push('ac3');
31 |
32 | if (hasEac3Support(videoTestElement)) {
33 | codecs.push('eac3');
34 | }
35 | }
36 |
37 | return codecs;
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/ts-video-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import { hasH264Support } from './mp4-video-formats';
6 |
7 | /**
8 | * List of supported ts video codecs
9 | */
10 | export function getSupportedTsVideoCodecs(
11 | videoTestElement: HTMLVideoElement
12 | ): string[] {
13 | const codecs = [];
14 |
15 | if (hasH264Support(videoTestElement)) {
16 | codecs.push('h264');
17 | }
18 |
19 | return codecs;
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/webm-audio-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import { isWebOS } from '@/utils/browser-detection';
6 |
7 | /**
8 | * Get an array of supported codecs
9 | */
10 | export function getSupportedWebMAudioCodecs(
11 | videoTestElement: HTMLVideoElement
12 | ): string[] {
13 | const codecs = [];
14 |
15 | codecs.push('vorbis');
16 |
17 | if (
18 | !isWebOS()
19 | && videoTestElement.canPlayType('audio/ogg; codecs="opus"').replace(/no/, '')
20 | ) {
21 | codecs.push('opus');
22 | }
23 |
24 | return codecs;
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/helpers/webm-video-formats.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import {
6 | hasAv1Support,
7 | hasVp8Support,
8 | hasVp9Support
9 | } from './mp4-video-formats';
10 |
11 | /**
12 | * Get an array of supported codecs WebM video codecs
13 | */
14 | export function getSupportedWebMVideoCodecs(
15 | videoTestElement: HTMLVideoElement
16 | ): string[] {
17 | const codecs = [];
18 |
19 | if (hasVp8Support(videoTestElement)) {
20 | codecs.push('vp8');
21 | }
22 |
23 | if (hasVp9Support(videoTestElement)) {
24 | codecs.push('vp9');
25 | }
26 |
27 | if (hasAv1Support(videoTestElement)) {
28 | codecs.push('av1');
29 | }
30 |
31 | return codecs;
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/utils/playback-profiles/subtitle-profile.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated - Check @/utils/playback-profiles/index
3 | */
4 |
5 | import {
6 | SubtitleDeliveryMethod,
7 | type SubtitleProfile
8 | } from '@jellyfin/sdk/lib/generated-client';
9 |
10 | /**
11 | * Returns a valid SubtitleProfile for the current platform.
12 | *
13 | * @returns An array of subtitle profiles for the current platform.
14 | */
15 | export function getSubtitleProfiles(): SubtitleProfile[] {
16 | const SubtitleProfiles: SubtitleProfile[] = [];
17 |
18 | SubtitleProfiles.push(
19 | {
20 | Format: 'vtt',
21 | Method: SubtitleDeliveryMethod.External
22 | },
23 | {
24 | Format: 'ass',
25 | Method: SubtitleDeliveryMethod.External
26 | },
27 | {
28 | Format: 'ssa',
29 | Method: SubtitleDeliveryMethod.External
30 | },
31 | {
32 | Format: 'pgssub',
33 | Method: SubtitleDeliveryMethod.External
34 | }
35 | );
36 |
37 | return SubtitleProfiles;
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/utils/validation.ts:
--------------------------------------------------------------------------------
1 | import type { AxiosError } from 'axios';
2 |
3 | /**
4 | * Validator to which enforces that a select component has at least one value selected
5 | */
6 | export const SomeItemSelectedRule = [
7 | (v: unknown[]): boolean | string => v.length !== 0
8 | ];
9 |
10 | /**
11 | * Check if the value is a number.
12 | */
13 | export function isNumber(value: unknown): value is number {
14 | return typeof value === 'number';
15 | }
16 |
17 | /**
18 | * Check if the value is a boolean.
19 | */
20 | export function isBool(value: unknown): value is boolean {
21 | return typeof value === 'boolean';
22 | }
23 |
24 | /**
25 | * Check if the value is a string.
26 | */
27 | export function isStr(value: unknown): value is string {
28 | return typeof value === 'string';
29 | }
30 |
31 | /**
32 | * Check if the given value is a funcion
33 | */
34 | export function isFunc(value: unknown): value is (...args: unknown[]) => unknown {
35 | return typeof value === 'function';
36 | }
37 |
38 | /**
39 | * Check if the value is undefined
40 | */
41 | export function isUndef(value: unknown): value is undefined {
42 | return value === undefined;
43 | }
44 |
45 | /**
46 | * Check if the value is null
47 | */
48 | export function isNull(value: unknown): value is null {
49 | return value === null;
50 | }
51 |
52 | /**
53 | * Check if the value is null or undefined
54 | */
55 | export function isNil(value: unknown): value is null | undefined {
56 | return isUndef(value) || isNull(value);
57 | }
58 |
59 | /**
60 | * Check if the value is an object.
61 | */
62 | export function isObj(value: unknown): value is object {
63 | return typeof value === 'object' && !isNull(value);
64 | }
65 |
66 | /**
67 | * TypeScript type guard for AxiosError
68 | */
69 | export function isAxiosError(object: unknown): object is AxiosError {
70 | return isObj(object) && 'isAxiosError' in object;
71 | }
72 |
73 | /**
74 | * Check if the value is an array
75 | */
76 | export function isArray(object: unknown): object is unknown[] {
77 | return Array.isArray(object);
78 | }
79 |
80 | /**
81 | * Seals a class
82 | *
83 | * @type TypeScript Decorator
84 | */
85 | export function sealed(constructor: new (...args: never[]) => object): void {
86 | Object.seal(constructor);
87 | Object.seal(constructor.prototype);
88 | }
89 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "Preserve",
5 | "moduleResolution": "Bundler",
6 | "esModuleInterop": true,
7 | "resolveJsonModule": true,
8 | "sourceMap": true,
9 | "strict": true,
10 | "skipLibCheck": true,
11 | "noEmit": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "useDefineForClassFields": true,
15 | "noImplicitThis": true,
16 | "noImplicitAny": true,
17 | "noErrorTruncation": true,
18 | "experimentalDecorators": true,
19 | "removeComments": true,
20 | "verbatimModuleSyntax": true,
21 | "incremental": true,
22 | "tsBuildInfoFile": "../node_modules/.cache/tsconfig.tsbuildinfo",
23 | "baseUrl": ".",
24 | "paths": {
25 | "@/*": ["src/*"]
26 | },
27 | "types": [
28 | "axios",
29 | "@intlify/unplugin-vue-i18n/messages",
30 | "unplugin-icons/types/vue",
31 | "unplugin-vue-router/client",
32 | "vite/client",
33 | "vuetify",
34 | "vue"
35 | ]
36 | },
37 | "vueCompilerOptions": {
38 | "strictTemplates": true,
39 | "htmlAttributes": ["aria-*", "data-*"],
40 | "fallthroughAttributes": true
41 | },
42 | "include": [
43 | "src/**/*.ts",
44 | "src/**/*.d.ts",
45 | "src/**/*.vue",
46 | "**/**/*.json",
47 | "types/**/*.d.ts",
48 | "*.config.*"
49 | ],
50 | "exclude": ["node_modules"]
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/types/global/attributes.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/consistent-indexed-object-style */
2 |
3 | declare module 'vue' {
4 | export interface AllowedComponentProps {
5 | [key: `data${string}`]: string;
6 | }
7 |
8 | export interface ComponentCustomProps {
9 | // Allow any data-* attr on Vue components
10 | [key: `data${string}`]: string;
11 | }
12 | }
13 | declare module 'vue' {
14 | export interface HTMLAttributes {
15 | // Allow any data-* attr on HTML elements
16 | [key: `data${string}`]: string;
17 | }
18 | }
19 |
20 | export {};
21 |
--------------------------------------------------------------------------------
/frontend/types/global/plugins.d.ts:
--------------------------------------------------------------------------------
1 | import type en from '@/../locales/en.json';
2 | import type { JTransitionProps } from '@/components/lib/JTransition.vue';
3 | import type { RemotePlugin } from '@/plugins/remote/types';
4 |
5 | /**
6 | * The object that represents RouteMeta is defined at @/plugins/router/middleware/meta
7 | */
8 | interface RouteTransitionPayload {
9 | enter?: NonNullable;
10 | leave?: JTransitionProps['name'];
11 | mode?: JTransitionProps['mode'];
12 | }
13 |
14 | interface LayoutPayload {
15 | readonly name?: 'default' | 'fullpage' | 'server';
16 | transparent?: boolean;
17 | transition: RouteTransitionPayload;
18 | }
19 | declare module 'vue-router' {
20 | interface RouteMeta {
21 | readonly layout: LayoutPayload;
22 | readonly admin?: boolean;
23 | title?: string | null;
24 | }
25 | }
26 |
27 | declare module 'vue' {
28 | interface ComponentCustomProperties {
29 | readonly $remote: RemotePlugin;
30 | }
31 | }
32 |
33 | declare module 'vue-i18n' {
34 | type messages = typeof en;
35 |
36 | export interface DefineLocaleMessage extends messages {}
37 | }
38 |
39 | /**
40 | * This is important: https://stackoverflow.com/a/64189046
41 | * https://www.typescriptlang.org/docs/handbook/modules.html
42 | */
43 |
44 | export { };
45 |
--------------------------------------------------------------------------------
/frontend/types/global/util.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * BetterOmit still provides IntelliSense fedback, unlike the built-in Omit type.
3 | * See https://github.com/microsoft/TypeScript/issues/56135
4 | */
5 | type BetterOmit = T extends Record
6 | ? {
7 | [P in keyof T as P extends K ? never : P]: T[P]
8 | }
9 | : T;
10 |
11 | /**
12 | * Make all the properties of a type mutable.
13 | */
14 | type Mutable = {
15 | -readonly [K in keyof T]: T[K];
16 | };
17 |
18 | /**
19 | * Gets the last item of a tuple
20 | */
21 | type Tail = L extends readonly [] ? L : L extends readonly [unknown?, ...infer LTail] ? LTail : L;
22 |
23 | /**
24 | * Sets a type as nullish
25 | */
26 | type Nullish = T | null | undefined;
27 |
--------------------------------------------------------------------------------
/frontend/types/modules/virtual.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'virtual:locales/date-fns' {
2 | import * as locales from 'date-fns/locale';
3 |
4 | export = locales;
5 | }
6 |
7 | declare module 'virtual:locales/vuetify' {
8 | import type * as locales from 'vuetify/locale';
9 |
10 | const typeWithoutRtl: BetterOmit;
11 |
12 | export = typeWithoutRtl;
13 | }
14 |
15 | declare module 'virtual:commit' {
16 | export const commit_hash: string | undefined;
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/uno.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, presetUno } from 'unocss';
2 |
3 | export default defineConfig({
4 | presets: [
5 | presetUno({
6 | prefix: 'uno-',
7 | preflight: false
8 | })
9 | ],
10 | theme: {
11 | colors: {
12 | background: 'rgb(var(--j-color-background))'
13 | }
14 | }
15 | });
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@jellyfin-vue",
3 | "private": true,
4 | "homepage": "https://jellyfin.org/",
5 | "bugs": {
6 | "url": "https://github.com/jellyfin/jellyfin-vue/issues"
7 | },
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/jellyfin/jellyfin-vue/"
11 | },
12 | "license": "GPL-3.0-only",
13 | "author": "jellyfin-vue Contributors (https://github.com/jellyfin/jellyfin-vue/graphs/contributors)",
14 | "workspaces": [
15 | "frontend",
16 | "packaging/tauri"
17 | ],
18 | "engines": {
19 | "node": ">=20.8.1 <21.0.0",
20 | "npm": ">=10.1.0",
21 | "yarn": "Yarn is not supported. Please use NPM."
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packaging/deb/README.md:
--------------------------------------------------------------------------------
1 | 1. Install `build-essential` and `debhelper`:
2 |
3 | `sudo apt install build-essential debhelper`
4 |
5 | 2. Build the package:
6 |
7 | `dpkg-buildpackage -us -uc`
8 |
--------------------------------------------------------------------------------
/packaging/deb/debian/changelog:
--------------------------------------------------------------------------------
1 | jellyfin-vue (0.2.0) unstable; urgency=medium
2 |
3 | * First prerelease package version
4 |
5 | -- Jellyfin Packaging Team Sun, 08 Jan 2023 12:44:59 -0500
6 |
--------------------------------------------------------------------------------
/packaging/deb/debian/compat:
--------------------------------------------------------------------------------
1 | 10
2 |
--------------------------------------------------------------------------------
/packaging/deb/debian/conffiles:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/patrickm53/jellyfin-vue/57607f1adc5b0b22780e53cadd55cce115b97a54/packaging/deb/debian/conffiles
--------------------------------------------------------------------------------
/packaging/deb/debian/control:
--------------------------------------------------------------------------------
1 | Source: jellyfin-vue
2 | Section: misc
3 | Priority: optional
4 | Maintainer: Jellyfin Team
5 | Build-Depends: debhelper (>= 10),
6 | nodejs
7 | Standards-Version: 3.9.4
8 | Homepage: https://jellyfin.org/
9 | Vcs-Git: https://github.org/jellyfin/jellyfin-vue.git
10 | Vcs-Browser: https://github.org/jellyfin/jellyfin-vue
11 |
12 | Package: jellyfin-vue
13 | Recommends: jellyfin-server
14 | Architecture: all
15 | Description: Jellyfin is the Free Software Media System.
16 | This package provides the Jellyfin vue.js client.
17 |
--------------------------------------------------------------------------------
/packaging/deb/debian/copyright:
--------------------------------------------------------------------------------
1 | Format: http://dep.debian.net/deps/dep5
2 | Upstream-Name: jellyfin-vue
3 | Source: https://github.com/jellyfin/jellyfin-vue
4 |
5 | Files: *
6 | Copyright: 2018-2023 Jellyfin Team
7 | License: GPL-3.0
8 |
9 | Files: debian/*
10 | Copyright: 2023 Joshua Boniface
11 | License: GPL-3.0
12 |
13 | License: GPL-3.0
14 | This package is free software; you can redistribute it and/or modify
15 | it under the terms of the GNU General Public License as published by
16 | the Free Software Foundation; either version 2 of the License, or
17 | (at your option) any later version.
18 | .
19 | This package is distributed in the hope that it will be useful,
20 | but WITHOUT ANY WARRANTY; without even the implied warranty of
21 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 | GNU General Public License for more details.
23 | .
24 | You should have received a copy of the GNU General Public License
25 | along with this program. If not, see
26 | .
27 | On Debian systems, the complete text of the GNU General
28 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".
29 |
--------------------------------------------------------------------------------
/packaging/deb/debian/gbp.conf:
--------------------------------------------------------------------------------
1 | [DEFAULT]
2 | pristine-tar = False
3 | cleaner = fakeroot debian/rules clean
4 |
5 | [import-orig]
6 | filter = [ ".git*", ".hg*", ".vs*", ".vscode*" ]
7 |
--------------------------------------------------------------------------------
/packaging/deb/debian/install:
--------------------------------------------------------------------------------
1 | dist usr/share/jellyfin/
2 |
--------------------------------------------------------------------------------
/packaging/deb/debian/po/POTFILES.in:
--------------------------------------------------------------------------------
1 | [type: gettext/rfc822deb] templates
2 |
--------------------------------------------------------------------------------
/packaging/deb/debian/po/templates.pot:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: jellyfin-server\n"
10 | "Report-Msgid-Bugs-To: jellyfin-server@packages.debian.org\n"
11 | "POT-Creation-Date: 2015-06-12 20:51-0600\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=CHARSET\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 |
20 | #. Type: note
21 | #. Description
22 | #: ../templates:1001
23 | msgid "Jellyfin permission info:"
24 | msgstr ""
25 |
26 | #. Type: note
27 | #. Description
28 | #: ../templates:1001
29 | msgid ""
30 | "Jellyfin by default runs under a user named \"jellyfin\". Please ensure that the "
31 | "user jellyfin has read and write access to any folders you wish to add to your "
32 | "library. Otherwise please run jellyfin under a different user."
33 | msgstr ""
34 |
35 | #. Type: string
36 | #. Description
37 | #: ../templates:2001
38 | msgid "Username to run Jellyfin as:"
39 | msgstr ""
40 |
41 | #. Type: string
42 | #. Description
43 | #: ../templates:2001
44 | msgid "The user that jellyfin will run as."
45 | msgstr ""
46 |
47 | #. Type: note
48 | #. Description
49 | #: ../templates:3001
50 | msgid "Jellyfin still running"
51 | msgstr ""
52 |
53 | #. Type: note
54 | #. Description
55 | #: ../templates:3001
56 | msgid "Jellyfin is currently running. Please close it and try again."
57 | msgstr ""
58 |
--------------------------------------------------------------------------------
/packaging/deb/debian/rules:
--------------------------------------------------------------------------------
1 | #! /usr/bin/make -f
2 | export DH_VERBOSE=1
3 |
4 | %:
5 | dh $@
6 |
7 | # disable "make check"
8 | override_dh_auto_test:
9 |
10 | # disable stripping debugging symbols
11 | override_dh_clistrip:
12 |
13 | override_dh_auto_build:
14 | cp -ra root/{package.json,package-lock.json,.npmrc,frontend} $(CURDIR)/
15 | npm ci --no-audit -w frontend
16 | npm run build -w frontend
17 | mv $(CURDIR)/frontend/dist $(CURDIR)/dist
18 | rm -rf $(CURDIR)/frontend rm -rf $(CURDIR)/node_modules $(CURDIR)/package.json $(CURDIR)/package-lock.json $(CURDIR)/.npmrc
19 |
20 | override_dh_auto_clean:
21 | test -d $(CURDIR)/frontend && rm -rf '$(CURDIR)/frontend' || true
22 | test -d $(CURDIR)/dist && rm -rf '$(CURDIR)/dist' || true
23 | test -d $(CURDIR)/node_modules && rm -rf '$(CURDIR)/node_modules' || true
24 | test -d $(CURDIR)/package.json && rm -rf '$(CURDIR)/package.json' || true
25 | test -d $(CURDIR)/package-lock.json && rm -rf '$(CURDIR)/package-lock.json' || true
26 | test -d $(CURDIR)/.npmrc && rm -rf '$(CURDIR)/.npmrc' || true
27 |
--------------------------------------------------------------------------------
/packaging/deb/debian/source/format:
--------------------------------------------------------------------------------
1 | 1.0
2 |
--------------------------------------------------------------------------------
/packaging/deb/debian/source/options:
--------------------------------------------------------------------------------
1 | tar-ignore='.git*'
2 | tar-ignore='**/.git'
3 | tar-ignore='**/.hg'
4 | tar-ignore='**/.vs'
5 | tar-ignore='**/.vscode'
6 | tar-ignore='deployment'
7 | tar-ignore='*.deb'
8 | tar-ignore='vue'
9 | tar-ignore='src/dist'
10 |
--------------------------------------------------------------------------------
/packaging/deb/root:
--------------------------------------------------------------------------------
1 | ../..
--------------------------------------------------------------------------------
/packaging/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | ## This dockerfile builds the client entirely in a Docker context
2 |
3 | # slim image can't be used since we need git to fetch the commit hash
4 | FROM node:20-slim AS build
5 |
6 | # Set build arguments
7 | ARG IS_STABLE=0
8 | ARG COMMIT_HASH
9 | # Set environment variables
10 | ENV IS_STABLE=$IS_STABLE
11 | ENV COMMIT_HASH=$COMMIT_HASH
12 |
13 | COPY package.json package-lock.json .npmrc /app/
14 | COPY frontend /app/frontend
15 | WORKDIR /app/frontend
16 |
17 | # Build client
18 | RUN npm ci --no-audit && npm run build
19 |
20 | # Deploy built distribution to nginx
21 | FROM nginx:stable-alpine-slim
22 |
23 | COPY packaging/docker/contents/nginx.conf /etc/nginx/conf.d/default.conf
24 | COPY packaging/docker/contents/*.sh /
25 | COPY LICENSE /usr/share/licenses/jellyfin-vue.LICENSE
26 |
27 | RUN rm -rf /usr/share/nginx/html/*
28 | COPY --from=build /app/frontend/dist/ /usr/share/nginx/html/
29 | RUN chmod +x /*.sh && /postunpack.sh && rm /postunpack.sh
30 | USER nginx
31 |
32 | EXPOSE 80
33 |
34 | # Set labels
35 | LABEL maintainer="Jellyfin Packaging Team - packaging@jellyfin.org"
36 | LABEL org.opencontainers.image.source="https://github.com/jellyfin/jellyfin-vue"
37 |
--------------------------------------------------------------------------------
/packaging/docker/README.md:
--------------------------------------------------------------------------------
1 | This Dockerfile must have it's context at the root of the repository. From this folder:
2 |
3 | ``
4 | docker build ../.. -t your/tag -f Dockerfile
5 | ``
6 |
7 | Alternatively, with the shell at the root of this repository:
8 |
9 | ``
10 | docker build . -t your/tag -f packaging/docker/Dockerfile
11 | ``
12 |
--------------------------------------------------------------------------------
/packaging/docker/contents/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ## If the command has not been replaced by the user (i.e docker run image /bin/sh),
4 | ## follow through the setup process
5 | if [[ "$*" = "nginx -g daemon off;" ]]; then
6 | echo "==== Starting Jellyfin Vue setup ===="
7 | echo
8 | /setup.sh
9 | echo
10 | echo "==== Setup finished! ===="
11 | echo -e "\n"
12 | fi
13 |
14 | exec "$@"
15 |
--------------------------------------------------------------------------------
/packaging/docker/contents/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | root /usr/share/nginx/html;
4 | location / {
5 | # First attempt to serve request as file, then as directory, then fall back to redirecting to index.html
6 | # This is needed for history mode in Vue router: https://router.vuejs.org/guide/essentials/history-mode.html#nginx
7 | try_files $uri $uri/ /index.html;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packaging/docker/contents/postunpack.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | echo "Configuring environment for nginx..."
3 |
4 | NGINX_CONFIG_FILE=/etc/nginx/nginx.conf
5 |
6 | ## setup.sh dependencies
7 | apk add --no-cache jq
8 |
9 | # CONTAINER ROOTLESS SETUP
10 | ### Set correct permissions and make frontend config.json file editable for the runtime user
11 | mkdir -p /run/nginx
12 | chown nginx:nginx -R /run/nginx /var/cache/nginx /usr/share/nginx/html/config.json
13 | sed -i 's|/var/run|/var/run/nginx|g' $NGINX_CONFIG_FILE
14 | ## The 'user' config option is useless when running rootless and gives a warning
15 | sed -i '/^user /d' $NGINX_CONFIG_FILE
16 | ## Allow to open privileged ports
17 | apk add --no-cache libcap
18 | setcap CAP_NET_BIND_SERVICE=+eip /usr/sbin/nginx
19 | apk --purge del libcap
20 |
21 | # Trim image
22 | apk --purge del apk-tools
23 | rm -rf /docker-entrypoint.d /.dockerenv /usr/sbin/nginx-debug
24 | rm -rf /usr/share/zoneinfo
25 | rm -rf /sbin/apk /etc/apk /lib/apk /usr/share/apk /var/lib/apk
26 | rm -rf /usr/lib/libcrypto* /usr/lib/libintl* /usr/lib/libssl* \
27 | /usr/lib/engines-3 /usr/lib/modules-load.d /usr/lib/nginx /usr/lib/ossl-modules
28 |
--------------------------------------------------------------------------------
/packaging/docker/contents/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | CONFIG_FILE_PATH="/usr/share/nginx/html/config.json"
4 | echo "Writing data to $CONFIG_FILE_PATH..."
5 |
6 | if [[ "$HISTORY_ROUTER_MODE" == "0" ]]; then
7 | ROUTER_MODE="hash"
8 | else
9 | ROUTER_MODE="history"
10 | fi
11 |
12 | if [[ "$DISABLE_SERVER_SELECTION" == "1" ]]; then
13 | ALLOW_SERVER_SELECTION=false
14 | else
15 | ALLOW_SERVER_SELECTION=true
16 | fi
17 |
18 | echo "DEFAULT_SERVERS value: $DEFAULT_SERVERS"
19 | echo "ALLOW_SERVER_SELECTION value: $ALLOW_SERVER_SELECTION"
20 | echo "ROUTER_MODE value: $ROUTER_MODE"
21 |
22 | output=$(jq -r --arg R_MODE "$ROUTER_MODE" --arg SERVS "$DEFAULT_SERVERS" '
23 | .defaultServerURLs = ($SERVS | split(",")) |
24 | .routerMode = $R_MODE |
25 | .allowServerSelection = ('"$ALLOW_SERVER_SELECTION"')
26 | ' $CONFIG_FILE_PATH
27 | )
28 |
29 | echo "$output" > $CONFIG_FILE_PATH
30 |
--------------------------------------------------------------------------------
/packaging/tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "jellyfin-vue-tauri"
3 | version = "0.0.0"
4 | description = "Jellyfin Tauri client"
5 | authors = [
6 | "jellyfin-vue Contributors (https://github.com/jellyfin/jellyfin-vue/graphs/contributors)"
7 | ]
8 | license = "GPL-3.0-only"
9 | repository = "https://github.com/jellyfin/jellyfin-vue"
10 | default-run = "jellyfin-vue-tauri"
11 | build = "src/build.rs"
12 | edition = "2021"
13 |
14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
15 |
16 | [build-dependencies]
17 | tauri-build = { version = "=2.0.0-rc.2", features = [] }
18 |
19 | [dependencies]
20 | tauri = { version = "=2.0.0-rc.2", features = [] }
21 |
22 | [features]
23 | default = ["custom-protocol"]
24 | custom-protocol = ["tauri/custom-protocol"]
25 |
26 | [profile.release]
27 | strip = true
28 | lto = true
29 | opt-level = 3
30 |
--------------------------------------------------------------------------------
/packaging/tauri/README.md:
--------------------------------------------------------------------------------
1 | The `apt_packages` file contains the system dependencies for Debian
2 | as mentioned in [Tauri prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites/#1-system-dependencies)
3 |
--------------------------------------------------------------------------------
/packaging/tauri/apt_packages:
--------------------------------------------------------------------------------
1 | libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
2 |
--------------------------------------------------------------------------------
/packaging/tauri/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@jellyfin-vue/tauri",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "start": "npm run gen-icon && tauri dev",
7 | "gen-icon": "tauri icon ../../frontend/public/icon.svg",
8 | "build": "npm run gen-icon && tauri build",
9 | "clean": "git clean -fxd"
10 | },
11 | "dependencies": {
12 | "@jellyfin-vue/frontend": "*"
13 | },
14 | "devDependencies": {
15 | "@tauri-apps/cli": "2.0.0-rc.3"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packaging/tauri/rustfmt.toml:
--------------------------------------------------------------------------------
1 | max_width = 100
2 | hard_tabs = false
3 | tab_spaces = 2
4 | newline_style = "Auto"
5 | use_small_heuristics = "Default"
6 | reorder_imports = true
7 | reorder_modules = true
8 | remove_nested_parens = true
9 | edition = "2018"
10 | merge_derives = true
11 | use_try_shorthand = false
12 | use_field_init_shorthand = false
13 | force_explicit_abi = true
14 | imports_granularity = "Crate"
15 |
--------------------------------------------------------------------------------
/packaging/tauri/src/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/packaging/tauri/src/main.rs:
--------------------------------------------------------------------------------
1 | #![cfg_attr(
2 | all(not(debug_assertions), target_os = "windows"),
3 | windows_subsystem = "windows"
4 | )]
5 |
6 | fn main() {
7 | tauri::Builder::default()
8 | .run(tauri::generate_context!())
9 | .expect("error while running tauri application");
10 | }
11 |
--------------------------------------------------------------------------------
/packaging/tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "bundle": {
3 | "active": true,
4 | "targets": [
5 | "appimage",
6 | "msi",
7 | "app"
8 | ],
9 | "longDescription": "",
10 | "shortDescription": "",
11 | "resources": [],
12 | "externalBin": [],
13 | "copyright": "",
14 | "category": "Entertainment",
15 | "linux": {
16 | "appimage": {
17 | "bundleMediaFramework": true
18 | }
19 | }
20 | },
21 | "build": {
22 | "beforeBuildCommand": "npm run build -w @jellyfin-vue/frontend",
23 | "frontendDist": "../../frontend/dist",
24 | "beforeDevCommand": "npm start -w @jellyfin-vue/frontend",
25 | "devUrl": "http://127.0.0.1:3000"
26 | },
27 | "productName": "jellyfin-vue",
28 | "version": "../../frontend/package.json",
29 | "identifier": "org.jellyfin.vue.tauri",
30 | "plugins": {},
31 | "app": {
32 | "security": {
33 | "csp": null
34 | },
35 | "windows": [
36 | {
37 | "title": "Jellyfin Vue",
38 | "width": 800,
39 | "height": 600,
40 | "resizable": true,
41 | "fullscreen": false
42 | }
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------