├── .browserslistrc ├── .editorconfig ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── auto_approve_dependabot.yml │ ├── lokalise-download.yml │ ├── pr-labels.yaml │ ├── release-drafter.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .hintrc ├── .pre-commit-config.yaml ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── env.d.ts ├── index.html ├── music-assistant-frontend.code-workspace ├── package.json ├── public ├── __init__.py ├── apple-touch-icon.png ├── favicon.ico ├── index.html ├── pwa-192x192.png ├── pwa-512x512.png ├── py.typed └── robots.txt ├── pyproject.toml ├── setup.cfg ├── src ├── App.vue ├── assets │ ├── JetBrainsMono-Medium.woff2 │ ├── aac.png │ ├── almost_silent.mp3 │ ├── cover_dark.png │ ├── cover_light.png │ ├── crossfade.png │ ├── default_artist.png │ ├── dsp-disabled.svg │ ├── dsp.svg │ ├── fallback.png │ ├── flac.png │ ├── flac_small.png │ ├── folder.svg │ ├── hires.png │ ├── icon.png │ ├── icon.svg │ ├── info_gradient.jpg │ ├── level.png │ ├── logo.svg │ ├── m4a.png │ ├── material-icons-outlined.woff2 │ ├── mp3.png │ ├── ogg.png │ ├── pcm.svg │ └── vorbis.png ├── components │ ├── AddManualLink.vue │ ├── BuiltinPlayer.vue │ ├── Carousel.vue │ ├── Chapters.vue │ ├── FavoriteButton.vue │ ├── HomeWidgetRows.vue │ ├── InfoHeader.vue │ ├── ItemsListing.vue │ ├── ListviewItem.vue │ ├── LyricsViewer.vue │ ├── MarqueeText.vue │ ├── MediaItemImages.vue │ ├── MediaItemThumb.vue │ ├── MenuButton.vue │ ├── PanelviewItem.vue │ ├── PanelviewItemCompact.vue │ ├── PlayerCard.vue │ ├── PlayersWidgetRow.vue │ ├── ProviderDetails.vue │ ├── ProviderIcon.vue │ ├── QualityDetailsBtn.vue │ ├── SvgIcon.vue │ ├── Toolbar.vue │ ├── VolumeControl.vue │ ├── WidgetRow.vue │ ├── dsp │ │ ├── DSPParametricEQ.vue │ │ ├── DSPPipeline.vue │ │ ├── DSPSlider.vue │ │ └── DSPToneControl.vue │ └── mods │ │ ├── Button.vue │ │ ├── Container.vue │ │ ├── ListItem.vue │ │ └── ResponsiveIcon.vue ├── constants.ts ├── helpers │ ├── index.ts │ ├── marquee_text_sync.ts │ ├── player_menu_items.ts │ ├── useMediaBrowserMetaData.ts │ └── utils.ts ├── layouts │ └── default │ │ ├── AddToPlaylistDialog.vue │ │ ├── BottomNavigation.vue │ │ ├── Default.vue │ │ ├── DrawerNavigation.vue │ │ ├── Footer.vue │ │ ├── ItemContextMenu.vue │ │ ├── PlayerOSD │ │ ├── Player.vue │ │ ├── PlayerBrowserMediaControls.vue │ │ ├── PlayerControlBtn │ │ │ ├── FavoriteBtn.vue │ │ │ ├── NextBtn.vue │ │ │ ├── PlayBtn.vue │ │ │ ├── PreviousBtn.vue │ │ │ ├── QueueBtn.vue │ │ │ ├── RepeatBtn.vue │ │ │ ├── ShuffleBtn.vue │ │ │ ├── SpeakerBtn.vue │ │ │ └── VolumeBtn.vue │ │ ├── PlayerControls.vue │ │ ├── PlayerExtendedControls.vue │ │ ├── PlayerFullscreen.vue │ │ ├── PlayerTimeline.vue │ │ ├── PlayerTrackDetails.vue │ │ └── PlayerVolume.vue │ │ ├── PlayerSelect.vue │ │ ├── ReloadPrompt.vue │ │ └── View.vue ├── main.ts ├── plugins │ ├── api │ │ ├── helpers.ts │ │ ├── index.ts │ │ └── interfaces.ts │ ├── breakpoint.ts │ ├── eventbus.ts │ ├── i18n.ts │ ├── index.ts │ ├── router.ts │ ├── store.ts │ ├── swiper.ts │ ├── touchEvents.ts │ ├── vuetify.ts │ └── web_player.ts ├── styles │ ├── _variables.scss │ ├── global.css │ └── settings.scss ├── translations │ ├── cs.json │ ├── da.json │ ├── de.json │ ├── en.json │ ├── en_AU.json │ ├── en_GB.json │ ├── es.json │ ├── fr.json │ ├── he.json │ ├── hu_HU.json │ ├── it.json │ ├── ko_KR.json │ ├── nb.json │ ├── nl.json │ ├── pl.json │ ├── pt_BR.json │ ├── pt_PT.json │ ├── ru_RU.json │ ├── sl_SI.json │ ├── sr.json │ ├── sr_Latn.json │ ├── sv_SE.json │ ├── uk_UA.json │ └── zh_CN.json ├── views │ ├── AlbumDetails.vue │ ├── ArtistDetails.vue │ ├── AudiobookDetails.vue │ ├── BrowseView.vue │ ├── HomeView.vue │ ├── LibraryAlbums.vue │ ├── LibraryArtists.vue │ ├── LibraryAudiobooks.vue │ ├── LibraryPlaylists.vue │ ├── LibraryPodcasts.vue │ ├── LibraryRadios.vue │ ├── LibraryTracks.vue │ ├── PlaylistDetails.vue │ ├── PodcastDetails.vue │ ├── RadioDetails.vue │ ├── Search.vue │ ├── TrackDetails.vue │ └── settings │ │ ├── AddPlayerGroup.vue │ │ ├── AddProvider.vue │ │ ├── CoreConfigs.vue │ │ ├── EditConfig.vue │ │ ├── EditCoreConfig.vue │ │ ├── EditPlayer.vue │ │ ├── EditPlayerDsp.vue │ │ ├── EditProvider.vue │ │ ├── FrontendConfig.vue │ │ ├── Players.vue │ │ ├── Providers.vue │ │ └── Settings.vue └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,jsx,ts,tsx,vue}] 4 | indent_style = space 5 | indent_size = 2 6 | single_quote = false 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | charset = utf-8 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | indent_style = space 17 | indent_size = 4 18 | trim_trailing_whitespace = true 19 | insert_final_newline = true 20 | end_of_line = lf 21 | charset = utf-8 22 | single_quote = false 23 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parser: 'vue-eslint-parser', 7 | parserOptions: { 8 | parser: '@typescript-eslint/parser', 9 | ecmaVersion: 2022, 10 | sourceType: 'module', 11 | }, 12 | extends: [ 13 | 'eslint:recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:prettier/recommended', 16 | 'plugin:vue/vue3-recommended', 17 | '@vue/eslint-config-typescript', 18 | 'prettier', 19 | 'plugin:prettier/recommended', 20 | ], 21 | plugins: ['prettier', '@typescript-eslint'], 22 | rules: { 23 | quotes: ['error', 'double'], 24 | 'prettier/prettier': [ 25 | 'error', 26 | { 27 | endOfLine: 'auto', 28 | }, 29 | ], 30 | 'vue/multi-word-component-names': 'off', 31 | 'vue/no-v-text-v-html-on-component': 'off', 32 | 'vue/no-v-html': 'off', 33 | 'vue/attribute-hyphenation': ['error', 'always', {ignore: ['onLoad']}], 34 | 'no-unused-vars': 'off', 35 | '@typescript-eslint/no-non-null-assertion': 'off', 36 | '@typescript-eslint/no-unused-vars': 'off', 37 | '@typescript-eslint/no-unused-vars-experimental': 'off', 38 | '@typescript-eslint/ban-ts-comment': 'off', 39 | '@typescript-eslint/no-explicit-any': 'off', 40 | 'vue/html-self-closing': [ 41 | 'error', 42 | { 43 | html: { 44 | void: 'always', 45 | normal: 'never', 46 | component: 'always', 47 | }, 48 | svg: 'always', 49 | math: 'always', 50 | }, 51 | ], 52 | }, 53 | ignorePatterns: ['dist'], 54 | }; 55 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.openhomefoundation.org 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | open-pull-requests-limit: 10 6 | schedule: 7 | interval: weekly 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "$RESOLVED_VERSION" 2 | tag-template: "$RESOLVED_VERSION" 3 | change-template: "- #$NUMBER - $TITLE (@$AUTHOR)" 4 | categories: 5 | - title: "⚠ Breaking Changes" 6 | labels: 7 | - "breaking-change" 8 | 9 | - title: "🐛 Bugfixes" 10 | labels: 11 | - "bugfix" 12 | 13 | - title: "🚀 Features" 14 | labels: 15 | - "feature" 16 | - "enhancement" 17 | - "new-feature" 18 | 19 | - title: "🧰 Maintenance" 20 | labels: 21 | - "ci" 22 | - "documentation" 23 | - "maintenance" 24 | 25 | - title: "⬆️ Dependencies" 26 | collapse-after: 1 27 | labels: 28 | - "dependencies" 29 | 30 | template: | 31 | ## What’s Changed 32 | 33 | $CHANGES 34 | 35 | version-resolver: 36 | major: 37 | labels: 38 | - "breaking-change" 39 | minor: 40 | labels: 41 | - "new-feature" 42 | default: patch 43 | -------------------------------------------------------------------------------- /.github/workflows/auto_approve_dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Auto approve Dependabot PR's 2 | 3 | on: pull_request_target 4 | 5 | jobs: 6 | auto-approve: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | pull-requests: write 10 | if: github.actor == 'dependabot[bot]' 11 | steps: 12 | - uses: hmarr/auto-approve-action@v4 13 | -------------------------------------------------------------------------------- /.github/workflows/lokalise-download.yml: -------------------------------------------------------------------------------- 1 | name: Download Lokalise translations 2 | on: 3 | schedule: 4 | - cron: "0 2 * * 2" 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Lokalise CLI 11 | run: curl -sfL https://raw.githubusercontent.com/lokalise/lokalise-cli-2-go/master/install.sh | sh 12 | - name: Pull 13 | env: 14 | VAR_LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_RO_API_TOKEN }} 15 | VAR_LOKALISE_PROJECT_ID: ${{ secrets.LOKALISE_PROJECT_ID }} 16 | VAR_LANGUAGES: zh_CN,cs,da,nl,en_au,en_GB,fr,de,hu_HU,it,ko_KR,nb,pl,pt_BR,pt_PT,ru_RU,sl_SI,sr,sr_Latn,es,sv_SE,uk_UA 17 | run: | 18 | ./bin/lokalise2 --token ${{ env.VAR_LOKALISE_API_TOKEN }} --project-id ${{ env.VAR_LOKALISE_PROJECT_ID }} file download --filter-langs ${{ env.VAR_LANGUAGES }} --format json --export-empty-as skip --export-sort first_added --placeholder-format printf --plural-format array --indentation 4sp --directory-prefix src/translations --replace-breaks=false 19 | - name: Create Pull Request 20 | env: 21 | GH_TOKEN: ${{ github.token }} 22 | GITHUB_NEW_BRANCH_NAME: Lokalise-${{ github.run_id }}${{ github.run_attempt }} 23 | run: | 24 | git checkout -b ${{ env.GITHUB_NEW_BRANCH_NAME }} 25 | git config --global user.name "${GITHUB_ACTOR}" 26 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" 27 | git add ./\*.json 28 | if [[ -z $(git status --untracked-files=no --porcelain) ]] 29 | then 30 | echo "No changes" 31 | else 32 | git commit -m 'Translations update' 33 | git push --set-upstream origin ${{ env.GITHUB_NEW_BRANCH_NAME }} 34 | gh pr create --base main --head ${{ env.GITHUB_NEW_BRANCH_NAME }} --title "Lokalise translations update" --body "" 35 | fi 36 | -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request: 7 | types: 8 | - synchronize 9 | - labeled 10 | - unlabeled 11 | branches: 12 | - main 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: ludeeus/action-require-labels@1.1.0 21 | with: 22 | labels: >- 23 | breaking-change, bugfix, enhancement, refactor, new-feature, maintenance, ci, dependencies 24 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Drafts your next Release notes as Pull Requests are merged into "main" 13 | - uses: release-drafter/release-drafter@v6.1.0 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish releases 2 | 3 | on: 4 | release: 5 | types: [published] 6 | env: 7 | PYTHON_VERSION: "3.10" 8 | NODE_VERSION: "18.x" 9 | NODE_OPTIONS: --max_old_space_size=6144 10 | 11 | # Set default workflow permissions 12 | # All scopes not mentioned here are set to no access 13 | # https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token 14 | permissions: 15 | actions: none 16 | 17 | jobs: 18 | release: 19 | name: Release 20 | runs-on: ubuntu-latest 21 | outputs: 22 | version: ${{ steps.vars.outputs.tag }} 23 | permissions: 24 | contents: write # Required to upload release assets 25 | steps: 26 | - name: Checkout the repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Get tag 30 | id: vars 31 | run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 32 | 33 | - name: Set up Python ${{ env.PYTHON_VERSION }} 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: ${{ env.PYTHON_VERSION }} 37 | 38 | - name: Set up Node ${{ env.NODE_VERSION }} 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: ${{ env.NODE_VERSION }} 42 | cache: yarn 43 | 44 | - name: Install dependencies 45 | run: | 46 | yarn install 47 | python3 -m pip install build tomli tomli-w 48 | 49 | - name: Set Python project version from tag 50 | shell: python 51 | run: |- 52 | import tomli 53 | import tomli_w 54 | 55 | with open("pyproject.toml", "rb") as f: 56 | pyproject = tomli.load(f) 57 | 58 | pyproject["project"]["version"] = "${{ steps.vars.outputs.tag }}" 59 | 60 | with open("pyproject.toml", "wb") as f: 61 | tomli_w.dump(pyproject, f) 62 | 63 | - name: Build and release package 64 | run: | 65 | yarn build 66 | rm -rf dist music_assistant_frontend.egg-info 67 | python3 -m build 68 | 69 | - name: Publish release to PyPI 70 | uses: pypa/gh-action-pypi-publish@v1.12.4 71 | with: 72 | user: __token__ 73 | password: ${{ secrets.PYPI_TOKEN }} 74 | 75 | - name: Upload release assets 76 | uses: softprops/action-gh-release@v2.2.2 77 | with: 78 | files: | 79 | dist/*.whl 80 | dist/*.tar.gz 81 | 82 | server-repo-pr: 83 | name: Server repo PR 84 | needs: release 85 | runs-on: ubuntu-latest 86 | steps: 87 | - uses: music-assistant/frontend-release-pr-action@main 88 | with: 89 | github_token: ${{ secrets.PRIVILEGED_GITHUB_TOKEN }} 90 | new_release_version: ${{ needs.release.outputs.version }} 91 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lint Vue frontend 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | 8 | env: 9 | NODE_VERSION: "18.x" # set this to the node version to use 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ env.NODE_VERSION }} 21 | - name: Get yarn cache directory path 22 | id: yarn-cache-dir-path 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | - name: Cache Node.js modules 25 | id: yarn-cache 26 | uses: actions/cache@v4 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | - name: Install dependencies 33 | run: yarn install --frozen-lockfile --silent 34 | env: 35 | CI: true 36 | - name: Lint 37 | run: yarn lint 38 | env: 39 | CI: true 40 | - name: Lint 41 | run: yarn build 42 | env: 43 | CI: true 44 | # disable typecheck for now 45 | # - name: Typecheck 46 | # run: yarn typecheck 47 | # env: 48 | # CI: true 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Editor directories and files 11 | .vs/ 12 | .vscode/* 13 | !.vscode/extensions.json 14 | .idea/ 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | *.sw? 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories and files 49 | node_modules/ 50 | jspm_packages/ 51 | package-lock.json 52 | 53 | # TypeScript v1 declaration files 54 | typings/ 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | 74 | # next.js build output 75 | .next 76 | 77 | dist/ 78 | dist-ssr/ 79 | 80 | patch.diff 81 | 82 | .DS_Store 83 | 84 | coverage 85 | 86 | *.local 87 | 88 | /cypress/videos/ 89 | /cypress/screenshots/ 90 | 91 | 92 | # build 93 | build 94 | music_assistant_frontend/* 95 | dist/ 96 | 97 | # yarn 98 | .yarn/* 99 | !.yarn/patches 100 | !.yarn/releases 101 | !.yarn/plugins 102 | !.yarn/sdks 103 | !.yarn/versions 104 | .pnp.* 105 | node_modules/* 106 | yarn-error.log 107 | npm-debug.log 108 | 109 | # Python stuff 110 | *.py[cod] 111 | *.egg 112 | *.egg-info 113 | 114 | # venv stuff 115 | pyvenv.cfg 116 | pip-selfcheck.json 117 | venv/* 118 | .venv 119 | 120 | # vscode 121 | .vscode/* 122 | !.vscode/extensions.json 123 | !.vscode/launch.json 124 | !.vscode/tasks.json 125 | -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "compat-api/css": [ 7 | "default", 8 | { 9 | "ignore": [ 10 | "-webkit-font-feature-settings" 11 | ] 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-json 6 | args: [] 7 | files: ^src/ 8 | exclude: tsconfig.json 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - id: debug-statements 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.2.6 14 | hooks: 15 | - id: codespell 16 | args: [] 17 | files: ^src/ 18 | exclude: ^src/translations/ 19 | additional_dependencies: 20 | - tomli 21 | 22 | - repo: local 23 | hooks: 24 | - id: lint 25 | name: yarn lint 26 | entry: yarn lint 27 | language: system 28 | files: ^src/ 29 | exclude_types: [csv, json, css, scss] 30 | exclude: ^src/assets/ 31 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "Vue.vscode-typescript-vue-plugin" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | graft music_assistant_frontend 3 | recursive-exclude * *.py[co] 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Music Assistant frontend (Vue PWA) 2 | 3 | The Music Assistant frontend/panel is developed in Vue, development instructions below. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | nvm use node 28 | yarn install 29 | ``` 30 | 31 | ### Compile and Hot-Reload for Development 32 | 33 | ```sh 34 | yarn dev 35 | ``` 36 | 37 | This will launch an auto-reload development environment (usually at http://localhost:3000) 38 | Open the url in the browser and a popup will ask the location of the MA server. 39 | You can either connect to a locally launched dev server or an existing running server on port 8095. 40 | 41 | ### Type-Check, Compile and Minify for Production 42 | 43 | ```sh 44 | yarn build 45 | ``` 46 | 47 | ### Lint with [ESLint](https://eslint.org/) 48 | 49 | ```sh 50 | yarn lint 51 | ``` 52 | 53 | # Translation Management 54 | 55 | We use Lokalise to manage the translation files for the Music Assistant frontend 56 | 57 | [](https://lokalise.com) 58 | 59 | ### Contributing 60 | 61 | If you wish to assist in translating Music Assistant into a language that it currently does not support, please see here https://music-assistant.io/help/lokalise/. 62 | 63 | --- 64 | 65 | [](https://www.openhomefoundation.org/) -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable no-unused-vars */ 3 | /// 4 | 5 | declare module "virtual:pwa-register/vue" { 6 | import type { Ref } from "vue"; 7 | 8 | export interface RegisterSWOptions { 9 | immediate?: boolean; 10 | onNeedRefresh?: () => void; 11 | onOfflineReady?: () => void; 12 | onRegistered?: ( 13 | registration: ServiceWorkerRegistration | undefined, 14 | ) => void; 15 | onRegisterError?: (error: any) => void; 16 | } 17 | 18 | export function useRegisterSW(options?: RegisterSWOptions): { 19 | needRefresh: Ref; 20 | offlineReady: Ref; 21 | updateServiceWorker: (reloadPage?: boolean) => Promise; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Music Assistant 15 | 16 | 17 | 18 | 19 | We're sorry but musicassistant-frontend doesn't work properly without 21 | JavaScript enabled. Please enable it to continue. 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /music-assistant-frontend.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".", 5 | }, 6 | ], 7 | "settings": { 8 | , 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "description": "The Music Assistant frontend developed in Vue.", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint . --fix --ignore-path .gitignore" 11 | }, 12 | "dependencies": { 13 | "@intlify/unplugin-vue-i18n": "6.0.8", 14 | "@mdi/font": "7.4.47", 15 | "@mdi/js": "7.4.47", 16 | "color": "5.0.0", 17 | "colorthief": "2.6.0", 18 | "home-assistant-js-websocket": "9.5.0", 19 | "marked": "^15.0.11", 20 | "mitt": "3.0.1", 21 | "mobile-detect": "1.4.5", 22 | "swiper": "^11.2.6", 23 | "vue": "3.5.13", 24 | "vue-audio-better": "3.0.1", 25 | "vue-i18n": "11.1.3", 26 | "vue-router": "4.5.1", 27 | "vuetify": "3.7.19", 28 | "websocket-ts": "2.2.1" 29 | }, 30 | "devDependencies": { 31 | "@fontsource/roboto": "5.2.5", 32 | "@types/color": "4.2.0", 33 | "@types/node": "22.15.18", 34 | "@typescript-eslint/eslint-plugin": "7.0.0", 35 | "@typescript-eslint/parser": "6.21.0", 36 | "@vitejs/plugin-vue": "5.2.4", 37 | "@vue/eslint-config-typescript": "13.0.0", 38 | "@vue/tsconfig": "0.7.0", 39 | "eslint": "8.57.1", 40 | "eslint-config-prettier": "10.1.5", 41 | "eslint-plugin-prettier": "5.4.0", 42 | "eslint-plugin-vue": "9.33.0", 43 | "https-localhost": "4.7.1", 44 | "jsdom": "26.1.0", 45 | "material-design-icons-iconfont": "6.7.0", 46 | "nanoid": "5.1.5", 47 | "prettier": "3.5.3", 48 | "replace-in-file": "8.3.0", 49 | "sass": "1.88.0", 50 | "typescript": "5.8.3", 51 | "unplugin": "^2.3.2", 52 | "vite": "^6.3.5", 53 | "vite-plugin-pwa": "1.0.0", 54 | "vite-plugin-vuetify": "2.1.1", 55 | "vite-plugin-webfont-dl": "3.10.4", 56 | "vite-svg-loader": "^5.1.0", 57 | "vue-tsc": "2.2.10" 58 | }, 59 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 60 | } 61 | -------------------------------------------------------------------------------- /public/__init__.py: -------------------------------------------------------------------------------- 1 | """Frontend for Music Assistant.""" 2 | from pathlib import Path 3 | 4 | 5 | def where() -> Path: 6 | """Return path to the frontend.""" 7 | return Path(__file__).parent 8 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Music Assistant 16 | 17 | 18 | 19 | 20 | We're sorry but musicassistant-frontend doesn't work properly without 22 | JavaScript enabled. Please enable it to continue. 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/public/py.typed -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "music-assistant-frontend" 3 | # The version is set by GH action on release 4 | version = "0.0.0" 5 | license = {text = "Apache-2.0"} 6 | description = "The Music Assistant frontend" 7 | readme = "README.md" 8 | authors = [ 9 | {name = "The Music Assistant Authors", email = "m.vanderveldt@outlook.com"} 10 | ] 11 | requires-python = ">=3.11.0" 12 | 13 | [project.urls] 14 | "Homepage" = "https://github.com/music-assistant/frontend" 15 | 16 | [tool.setuptools] 17 | platforms = ["any"] 18 | zip-safe = false 19 | include-package-data = true 20 | 21 | [tool.setuptools.packages.find] 22 | include = ["music_assistant_frontend"] 23 | 24 | [tool.codespell] 25 | ignore-words-list = "provid,hass,followings" 26 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). 2 | # Keep this file until it does! 3 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 14 | 15 | 16 | 126 | -------------------------------------------------------------------------------- /src/assets/JetBrainsMono-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/JetBrainsMono-Medium.woff2 -------------------------------------------------------------------------------- /src/assets/aac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/aac.png -------------------------------------------------------------------------------- /src/assets/almost_silent.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/almost_silent.mp3 -------------------------------------------------------------------------------- /src/assets/cover_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/cover_dark.png -------------------------------------------------------------------------------- /src/assets/cover_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/cover_light.png -------------------------------------------------------------------------------- /src/assets/crossfade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/crossfade.png -------------------------------------------------------------------------------- /src/assets/default_artist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/default_artist.png -------------------------------------------------------------------------------- /src/assets/dsp-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 39 | 47 | 48 | -------------------------------------------------------------------------------- /src/assets/dsp.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 39 | 40 | -------------------------------------------------------------------------------- /src/assets/fallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/fallback.png -------------------------------------------------------------------------------- /src/assets/flac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/flac.png -------------------------------------------------------------------------------- /src/assets/flac_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/flac_small.png -------------------------------------------------------------------------------- /src/assets/folder.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/hires.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/hires.png -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/info_gradient.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/info_gradient.jpg -------------------------------------------------------------------------------- /src/assets/level.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/level.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/m4a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/m4a.png -------------------------------------------------------------------------------- /src/assets/material-icons-outlined.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/material-icons-outlined.woff2 -------------------------------------------------------------------------------- /src/assets/mp3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/mp3.png -------------------------------------------------------------------------------- /src/assets/ogg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/ogg.png -------------------------------------------------------------------------------- /src/assets/pcm.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 39 | 41 | 45 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/assets/vorbis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/assets/vorbis.png -------------------------------------------------------------------------------- /src/components/AddManualLink.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 18 | 24 | 30 | 31 | 32 | {{ 33 | $t("cancel") 34 | }} 35 | {{ $t("save") }} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 156 | -------------------------------------------------------------------------------- /src/components/Carousel.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /src/components/Chapters.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | {{ chapter.position }} 21 | 22 | 23 | 24 | 25 | {{ chapter.name }} 26 | 27 | 28 | {{ formatDuration(chapter.end - chapter.start) }} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 80 | -------------------------------------------------------------------------------- /src/components/FavoriteButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/HomeWidgetRows.vue: -------------------------------------------------------------------------------- 1 | 2 | onUpdateSettings('players', settings)" 7 | /> 8 | 14 | onUpdateSettings(widgetRow.uri!, settings) 20 | " 21 | /> 22 | 23 | 24 | 25 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /src/components/MenuButton.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 21 | 22 | 23 | 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 66 | -------------------------------------------------------------------------------- /src/components/PlayersWidgetRow.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ $t("players") }} 8 | 9 | 10 | 11 | 12 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 51 | 52 | 53 | 54 | 55 | 56 | 102 | 103 | 144 | -------------------------------------------------------------------------------- /src/components/ProviderIcon.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 14 | 15 | 25 | 29 | 30 | 31 | 41 | 45 | 46 | 47 | 55 | 59 | 60 | 61 | 68 | 69 | 75 | 76 | 77 | 78 | 97 | 104 | -------------------------------------------------------------------------------- /src/components/SvgIcon.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 65 | -------------------------------------------------------------------------------- /src/components/WidgetRow.vue: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | {{ widgetRow.title }} 12 | 20 | 21 | 22 | 23 | 24 | 35 | 36 | 45 | 46 | 59 | 60 | 65 | 66 | 67 | 68 | 69 | 70 | 80 | 81 | 82 | 83 | {{ $t("no_content") }} 84 | 85 | 86 | 87 | 88 | 123 | 124 | 164 | -------------------------------------------------------------------------------- /src/components/dsp/DSPSlider.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ 5 | config.label 6 | }} 7 | 17 | 26 | {{ config.unit }} 27 | 28 | 29 | 30 | 31 | 142 | -------------------------------------------------------------------------------- /src/components/dsp/DSPToneControl.vue: -------------------------------------------------------------------------------- 1 | 2 | 13 | 24 | 35 | 36 | 42 | -------------------------------------------------------------------------------- /src/components/mods/Button.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 80 | 81 | 92 | -------------------------------------------------------------------------------- /src/components/mods/Container.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 57 | 58 | 85 | -------------------------------------------------------------------------------- /src/components/mods/ListItem.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | $emit('input', v)" 12 | @click.right.prevent="(v: any) => $emit('menu', v)" 13 | > 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | $emit('menu', v)" 30 | /> 31 | 32 | 33 | 34 | 35 | 57 | 58 | 99 | -------------------------------------------------------------------------------- /src/components/mods/ResponsiveIcon.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 23 | 24 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 120 | 121 | 159 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_MENU_ITEMS = [ 2 | "home", 3 | "search", 4 | "artists", 5 | "albums", 6 | "tracks", 7 | "playlists", 8 | "audiobooks", 9 | "podcasts", 10 | "radios", 11 | "browse", 12 | "settings", 13 | ]; 14 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/music-assistant/frontend/4d1f59b484858fc80a246c7fab57a86fc3a4e4b6/src/helpers/index.ts -------------------------------------------------------------------------------- /src/helpers/marquee_text_sync.ts: -------------------------------------------------------------------------------- 1 | type ComponentId = symbol; 2 | type SyncResolver = () => void; 3 | 4 | class RunningAnimationSync { 5 | private parentSync: MarqueeTextSync; 6 | private readonly id: ComponentId; 7 | // resolve method of the promise returned by `sync()` 8 | private promiseResolver: SyncResolver | null = null; 9 | 10 | constructor(parentSync: MarqueeTextSync) { 11 | this.parentSync = parentSync; 12 | this.id = Symbol(); 13 | } 14 | 15 | // Call this when a animation is started with the time it will take to scroll 16 | // the whole text (without constant delays) 17 | // Must be called if you wan't to use `sync()` 18 | setScrollingDuration(duration: number): void { 19 | if (duration > 0) { 20 | this.parentSync._componentDurations.set(this.id, duration); 21 | } else { 22 | this.unregister(); 23 | } 24 | } 25 | 26 | // Returns the maximum duration set by `setScrollingDuration` of all running animations 27 | maxDuration(): number { 28 | if (this.parentSync._componentDurations.size === 0) return 0; 29 | return Math.max(...this.parentSync._componentDurations.values()); 30 | } 31 | 32 | // Returns a promise that resolves when all currently running animations 33 | // are awaiting this sync point 34 | // the returned promise must be immediatly awaited 35 | sync(): Promise { 36 | return new Promise((resolve) => { 37 | // If the user awaits sync(), we can assume that only a single promiseResolver 38 | // exists at a time, so we can safely overwrite it 39 | this.promiseResolver = resolve; 40 | this.parentSync._pendingSync.push(resolve); 41 | this.parentSync._tryResolveWaiting(); 42 | }); 43 | } 44 | 45 | // Make sure to call this when: 46 | // - the component is unmounted 47 | // - no animation is playing anymore, i.e. the animation was aborted 48 | // do not call `sync()` after calling this, first call `setScrollingDuration` again 49 | unregister(): void { 50 | if (this.promiseResolver) { 51 | const index = this.parentSync._pendingSync.indexOf(this.promiseResolver); 52 | if (index > -1) { 53 | this.parentSync._pendingSync.splice(index, 1); 54 | } 55 | this.promiseResolver(); // resolve to avoid potential memory leaks 56 | this.promiseResolver = null; 57 | } 58 | this.parentSync._componentDurations.delete(this.id); 59 | // free up waiting syncs if needed (now that we compleatly removed ourself) 60 | this.parentSync._tryResolveWaiting(); 61 | } 62 | } 63 | 64 | // Pass this to the `sync` prop of the MarqueeText component to sync all animations 65 | export class MarqueeTextSync { 66 | _componentDurations: Map; // durations of all running animations 67 | _pendingSync: SyncResolver[]; // resolvers of all pending sync promises 68 | 69 | constructor() { 70 | this._componentDurations = new Map(); 71 | this._pendingSync = []; 72 | } 73 | 74 | _registerAnimation(): RunningAnimationSync { 75 | return new RunningAnimationSync(this); 76 | } 77 | 78 | // Will resolve all pending syncs in case all animations are waiting for it 79 | _tryResolveWaiting(): void { 80 | if (this._pendingSync?.length === this._componentDurations.size) { 81 | // Everyone is waiting for this sync point, resolve all 82 | const pending = this._pendingSync; // to avoid race conditions 83 | this._pendingSync = []; 84 | for (const resolve of pending) { 85 | resolve(); 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/helpers/useMediaBrowserMetaData.ts: -------------------------------------------------------------------------------- 1 | import { getImageThumbForItem } from "@/components/MediaItemThumb.vue"; 2 | import api from "@/plugins/api"; 3 | import { 4 | ImageType, 5 | MediaType, 6 | QueueItem, 7 | Track, 8 | } from "@/plugins/api/interfaces"; 9 | import { store } from "@/plugins/store"; 10 | import { computed, watch } from "vue"; 11 | 12 | function playerMediaToMetadata(item: QueueItem) { 13 | let artist: string | undefined; 14 | let album: string | undefined; 15 | 16 | //here we cast to Track to access properties that are only available on Track 17 | if (item.media_item?.media_type === MediaType.TRACK) { 18 | const currentMedia = item.media_item as Track; 19 | artist = currentMedia.artists.map((a) => a.name).join(", "); 20 | album = currentMedia.album?.name; 21 | } 22 | const artwork = [ 23 | { 24 | src: getImageThumbForItem(item, ImageType.THUMB, 128) || "", 25 | sizes: "128x128", 26 | }, 27 | { 28 | src: getImageThumbForItem(item, ImageType.THUMB, 256) || "", 29 | sizes: "256x256", 30 | }, 31 | { 32 | src: getImageThumbForItem(item, ImageType.THUMB, 512) || "", 33 | sizes: "512x512", 34 | }, 35 | ]; 36 | 37 | return new MediaMetadata({ 38 | title: item.media_item!.name, 39 | artist, 40 | album, 41 | artwork: artwork, 42 | }); 43 | } 44 | // If no player_id is passed, the currently selected player is shown 45 | export function useMediaBrowserMetaData(player_id?: string) { 46 | let player; 47 | if (player_id === undefined) { 48 | player = computed(() => { 49 | if (store.activePlayerId && store.activePlayerId in api.players) { 50 | return api.players[store.activePlayerId]; 51 | } 52 | return undefined; 53 | }); 54 | } else { 55 | player = computed(() => { 56 | if (player_id in api.players) { 57 | return api.players[player_id]; 58 | } 59 | return undefined; 60 | }); 61 | } 62 | 63 | const playerQueue = computed(() => { 64 | if ( 65 | player.value?.active_source && 66 | player.value.active_source in api.queues 67 | ) { 68 | return api.queues[player.value.active_source]; 69 | } 70 | if ( 71 | player.value && 72 | !player.value.active_source && 73 | player.value.player_id in api.queues && 74 | api.queues[player.value.player_id].active 75 | ) { 76 | return api.queues[player.value.player_id]; 77 | } 78 | return undefined; 79 | }); 80 | const queueItem = computed(() => { 81 | if (playerQueue.value && playerQueue.value.active) 82 | return playerQueue.value.current_item; 83 | return undefined; 84 | }); 85 | let currentMediaUri: string | undefined; 86 | 87 | //watch the current media to update the metadata 88 | const unwatch_metadata = watch( 89 | () => queueItem.value, 90 | (newMedia) => { 91 | if (!newMedia || !newMedia.media_item) return; 92 | //Lets make sure that the new media isn't spammed 93 | if (newMedia.media_item.uri === currentMediaUri) return; 94 | const newMediaMetaData = playerMediaToMetadata(newMedia); 95 | currentMediaUri = newMedia.media_item.uri; 96 | navigator.mediaSession.metadata = newMediaMetaData; 97 | }, 98 | { immediate: true }, 99 | ); 100 | const unwatch_position = watch( 101 | () => [ 102 | playerQueue.value?.elapsed_time, 103 | playerQueue.value?.current_item?.duration, 104 | ], 105 | () => { 106 | if ( 107 | !playerQueue.value?.active || 108 | store.activePlayerQueue?.current_item?.media_item?.media_type !== 109 | MediaType.TRACK 110 | ) { 111 | // Clear the progress bar. 112 | navigator.mediaSession.setPositionState(); 113 | return; 114 | } 115 | const duration = playerQueue.value?.current_item?.duration || 1; 116 | const position = Math.min(duration, playerQueue.value?.elapsed_time || 0); 117 | navigator.mediaSession.setPositionState({ 118 | duration: duration, 119 | playbackRate: 1.0, 120 | position: position, 121 | }); 122 | }, 123 | { immediate: true }, 124 | ); 125 | return () => { 126 | unwatch_metadata(); 127 | unwatch_position(); 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /src/layouts/default/BottomNavigation.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 18 | {{ menuItem.icon }} 19 | {{ $t(menuItem.label) }} 20 | 21 | 22 | 23 | 24 | 25 | 32 | {{ menuItem.icon }} 33 | {{ $t(menuItem.label) }} 34 | 35 | 36 | 37 | 38 | 44 | mdi-bookshelf 45 | {{ $t("library") }} 46 | 47 | 48 | 49 | 50 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 77 | 78 | 95 | -------------------------------------------------------------------------------- /src/layouts/default/Default.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 72 | 73 | 81 | -------------------------------------------------------------------------------- /src/layouts/default/Footer.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 18 | 30 | 31 | 32 | 33 | 34 | 59 | 60 | 96 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/FavoriteBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 40 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/NextBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 49 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/PlayBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 54 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/PreviousBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 49 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/QueueBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 22 | 51 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/RepeatBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 34 | 35 | 36 | 55 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/ShuffleBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 27 | 28 | 29 | 48 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/SpeakerBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 18 | 19 | 20 | 21 | 31 | 32 | 37 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControlBtn/VolumeBtn.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 0 27 | ? api.playerCommandGroupVolume( 28 | store.activePlayer?.player_id || '', 29 | $event, 30 | ) 31 | : api.playerCommandVolumeSet( 32 | store.activePlayer?.player_id || '', 33 | $event, 34 | ) 35 | " 36 | > 37 | 38 | 39 | 45 | 50 | 51 | {{ 52 | store.activePlayer!.group_childs.length > 0 53 | ? Math.round(store.activePlayer?.group_volume || 0) 54 | : Math.round(store.activePlayer?.volume_level || 0) 55 | }} 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 68 | {{ 69 | store.activePlayer!.group_childs.length > 0 70 | ? Math.round(store.activePlayer?.group_volume || 0) 71 | : Math.round(store.activePlayer?.volume_level || 0) 72 | }} 73 | 74 | 75 | 76 | 77 | 78 | 87 | 97 | 98 | 99 | 100 | 101 | 102 | 130 | 131 | 143 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerControls.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 13 | 14 | 15 | 19 | 25 | 26 | 27 | 28 | 35 | 36 | 37 | 41 | 48 | 49 | 50 | 54 | 60 | 61 | 62 | 63 | 64 | 110 | 111 | 124 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerExtendedControls.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 14 | 15 | 16 | 51 | -------------------------------------------------------------------------------- /src/layouts/default/PlayerOSD/PlayerVolume.vue: -------------------------------------------------------------------------------- 1 | 2 | $emit('update:model-value', value)" 10 | > 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 65 | 66 | 75 | -------------------------------------------------------------------------------- /src/layouts/default/ReloadPrompt.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | App ready to work offline 16 | 17 | New content available, click on reload button to update. 18 | 19 | 20 | Reload 21 | Close 22 | 23 | 24 | 25 | 50 | -------------------------------------------------------------------------------- /src/layouts/default/View.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | (store.activeAlert = undefined)" 21 | /> 22 | 23 | 24 | 25 | 31 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * main.ts 3 | * 4 | * Bootstraps Vuetify and other plugins then mounts the App` 5 | */ 6 | 7 | // Global styles 8 | import "@/styles/global.css"; 9 | 10 | // Components 11 | import App from "./App.vue"; 12 | 13 | // Composables 14 | import { createApp } from "vue"; 15 | 16 | // Plugins 17 | import { registerPlugins } from "@/plugins"; 18 | 19 | const app = createApp(App); 20 | 21 | registerPlugins(app); 22 | 23 | app.mount("#app"); 24 | -------------------------------------------------------------------------------- /src/plugins/api/helpers.ts: -------------------------------------------------------------------------------- 1 | // several helpers for dealing with the api and its (media) items 2 | 3 | import api from "."; 4 | import { MediaItemType, ItemMapping, MediaType, Player } from "./interfaces"; 5 | 6 | export const itemIsAvailable = function ( 7 | item: MediaItemType | ItemMapping, 8 | ): boolean { 9 | if (item.media_type == MediaType.FOLDER) return true; 10 | if ("provider_mappings" in item) { 11 | for (const x of item.provider_mappings) { 12 | if (x.available && api.providers[x.provider_instance]?.available) 13 | return true; 14 | } 15 | } else if ("available" in item) return item.available as boolean; 16 | return false; 17 | }; 18 | 19 | export const getSourceName = function (player: Player) { 20 | const source_id = player.active_source || ""; 21 | // source id is a queue id 22 | if (source_id in api.queues) return api.queues[source_id].display_name; 23 | for (const source of player.source_list) { 24 | if (source_id == source.id) { 25 | return source.name; 26 | } 27 | } 28 | return source_id; 29 | }; 30 | -------------------------------------------------------------------------------- /src/plugins/eventbus.ts: -------------------------------------------------------------------------------- 1 | // Global, simple eventbus 2 | 3 | import mitt, { Emitter } from "mitt"; 4 | import { MediaItemType } from "./api/interfaces"; 5 | import { ContextMenuItem } from "@/layouts/default/ItemContextMenu.vue"; 6 | 7 | export type PlaylistDialogEvent = { 8 | items: MediaItemType[]; 9 | parentItem?: MediaItemType; 10 | }; 11 | 12 | export type ContextMenuDialogEvent = { 13 | items: ContextMenuItem[]; 14 | posX?: number; 15 | posY?: number; 16 | showPlayMenuHeader?: boolean; 17 | }; 18 | 19 | export type Events = { 20 | contextmenu: ContextMenuDialogEvent; 21 | playlistdialog: PlaylistDialogEvent; 22 | }; 23 | 24 | export const eventbus: Emitter = mitt(); 25 | -------------------------------------------------------------------------------- /src/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { createI18n } from "vue-i18n"; 3 | 4 | /* 5 | * All i18n resources specified in the plugin `include` option can be loaded 6 | * at once using the import syntax 7 | */ 8 | import messages from "@intlify/unplugin-vue-i18n/messages"; 9 | 10 | const i18n = createI18n({ 11 | legacy: false, 12 | globalInjection: true, 13 | locale: navigator.language.split("-")[0], 14 | fallbackLocale: "en", 15 | missingWarn: false, 16 | fallbackWarn: false, 17 | silentTranslationWarn: true, 18 | messages, 19 | }); 20 | 21 | // @ts-ignore 22 | const $t = i18n.global.t; 23 | 24 | export { i18n, $t }; 25 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/index.ts 3 | * 4 | * Automatically included in `./src/main.ts` 5 | */ 6 | 7 | // Plugins 8 | import vuetify from "./vuetify"; 9 | import router from "./router"; 10 | import { i18n } from "./i18n"; 11 | import touchEvents from "./touchEvents"; 12 | import breakpoint from "./breakpoint"; 13 | import swiper from "./swiper"; 14 | import type { App } from "vue"; 15 | 16 | export function registerPlugins(app: App) { 17 | app 18 | .use(vuetify) 19 | .use(router) 20 | .use(i18n) 21 | .use(touchEvents) 22 | .use(breakpoint) 23 | .use(swiper); 24 | } 25 | -------------------------------------------------------------------------------- /src/plugins/store.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive } from "vue"; 2 | import { MediaType, Player, PlayerQueue, QueueItem } from "./api/interfaces"; 3 | 4 | import api from "./api"; 5 | import { StoredState } from "@/components/ItemsListing.vue"; 6 | import { isTouchscreenDevice } from "@/helpers/utils"; 7 | 8 | import MobileDetect from "mobile-detect"; 9 | 10 | type DeviceType = "desktop" | "phone" | "tablet"; 11 | const md = new MobileDetect(window.navigator.userAgent); 12 | 13 | const DEVICE_TYPE: DeviceType = md.tablet() 14 | ? "tablet" 15 | : md.phone() || md.mobile() 16 | ? "phone" 17 | : "desktop"; 18 | 19 | export enum AlertType { 20 | ERROR = "error", 21 | WARNING = "warning", 22 | INFO = "info", 23 | SUCCESS = "success", 24 | } 25 | 26 | interface Alert { 27 | type: AlertType; 28 | message: string; 29 | persistent: boolean; 30 | } 31 | 32 | interface Store { 33 | activePlayerId?: string; 34 | isInStandaloneMode: boolean; 35 | showPlayersMenu: boolean; 36 | navigationMenuStyle: string; 37 | showFullscreenPlayer: boolean; 38 | frameless: boolean; 39 | showQueueItems: boolean; 40 | apiInitialized: boolean; 41 | apiBaseUrl: string; 42 | dialogActive: boolean; 43 | activePlayer?: Player; 44 | activePlayerQueue?: PlayerQueue; 45 | curQueueItem?: QueueItem; 46 | globalSearchTerm?: string; 47 | globalSearchType?: MediaType; 48 | prevState?: StoredState; 49 | activeAlert?: Alert; 50 | prevRoute?: string; 51 | libraryArtistsCount?: number; 52 | libraryAlbumsCount?: number; 53 | libraryTracksCount?: number; 54 | libraryPlaylistsCount?: number; 55 | libraryRadiosCount?: number; 56 | libraryPodcastsCount?: number; 57 | libraryAudiobooksCount?: number; 58 | connected?: boolean; 59 | isTouchscreen: boolean; 60 | playMenuShown: boolean; 61 | playActionInProgress: boolean; 62 | deviceType: DeviceType; 63 | } 64 | 65 | export const store: Store = reactive({ 66 | activePlayerId: undefined, 67 | isInStandaloneMode: false, 68 | showPlayersMenu: false, 69 | navigationMenuStyle: "horizontal", 70 | showFullscreenPlayer: false, 71 | frameless: false, 72 | showQueueItems: false, 73 | apiInitialized: false, 74 | apiBaseUrl: "", 75 | dialogActive: false, 76 | activePlayer: computed(() => { 77 | if (store.activePlayerId && store.activePlayerId in api.players) { 78 | return api.players[store.activePlayerId]; 79 | } 80 | return undefined; 81 | }), 82 | activePlayerQueue: computed(() => { 83 | if ( 84 | store.activePlayer?.active_source && 85 | store.activePlayer.active_source in api.queues 86 | ) { 87 | return api.queues[store.activePlayer.active_source]; 88 | } 89 | if ( 90 | store.activePlayer && 91 | !store.activePlayer.active_source && 92 | store.activePlayer.player_id in api.queues && 93 | api.queues[store.activePlayer.player_id].active 94 | ) { 95 | return api.queues[store.activePlayer.player_id]; 96 | } 97 | return undefined; 98 | }), 99 | curQueueItem: computed(() => { 100 | if (store.activePlayerQueue && store.activePlayerQueue.active) 101 | return store.activePlayerQueue.current_item; 102 | return undefined; 103 | }), 104 | globalSearchTerm: undefined, 105 | globalSearchType: undefined, 106 | prevState: undefined, 107 | activeAlert: undefined, 108 | prevRoute: undefined, 109 | libraryArtistsCount: undefined, 110 | libraryAlbumsCount: undefined, 111 | libraryTracksCount: undefined, 112 | libraryPlaylistsCount: undefined, 113 | libraryRadiosCount: undefined, 114 | connected: false, 115 | isTouchscreen: isTouchscreenDevice(), 116 | playMenuShown: false, 117 | playActionInProgress: false, 118 | deviceType: DEVICE_TYPE, 119 | }); 120 | -------------------------------------------------------------------------------- /src/plugins/swiper.ts: -------------------------------------------------------------------------------- 1 | import { Swiper, SwiperSlide } from "swiper/vue"; 2 | import SwiperCore from "swiper"; 3 | import { Navigation, Pagination, FreeMode, Mousewheel } from "swiper/modules"; 4 | import "swiper/css"; 5 | import "swiper/css/free-mode"; 6 | import "swiper/css/mousewheel"; 7 | import "swiper/swiper-bundle.css"; 8 | import { App } from "vue"; 9 | 10 | export default { 11 | install(app: App) { 12 | SwiperCore.use([Pagination, Navigation, FreeMode, Mousewheel]); 13 | app.component("Swiper", Swiper).component("SwiperSlide", SwiperSlide); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/vuetify.ts 3 | * 4 | * Framework documentation: https://vuetifyjs.com` 5 | */ 6 | 7 | // Styles 8 | import "@mdi/font/css/materialdesignicons.css"; 9 | import "material-design-icons-iconfont/dist/material-design-icons.css"; 10 | import "vuetify/styles"; 11 | import colors from "vuetify/lib/util/colors"; 12 | import { aliases as defaultAliases, mdi } from "vuetify/iconsets/mdi"; 13 | import { md } from "vuetify/iconsets/md"; 14 | 15 | // Composables 16 | import { IconAliases, createVuetify } from "vuetify"; 17 | 18 | const aliases: IconAliases = { 19 | ...defaultAliases, 20 | }; 21 | 22 | export default createVuetify( 23 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 24 | { 25 | icons: { 26 | defaultSet: "mdi", 27 | aliases, 28 | sets: { 29 | md, 30 | mdi, 31 | }, 32 | }, 33 | display: { 34 | mobileBreakpoint: "md", 35 | thresholds: { 36 | xs: 0, 37 | sm: 340, 38 | md: 540, 39 | lg: 800, 40 | xl: 1280, 41 | }, 42 | }, 43 | theme: { 44 | defaultTheme: "light", 45 | themes: { 46 | light: { 47 | dark: false, 48 | colors: { 49 | primary: colors.blue.base, 50 | accent: colors.blue.darken2, 51 | }, 52 | }, 53 | dark: { 54 | dark: true, 55 | colors: { 56 | primary: colors.blue.darken4, 57 | accent: colors.blue.lighten2, 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | ); 64 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | // Place SASS variable overrides here 2 | // $font-size-root: 18px; 3 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | /* Various global styles used throughout the app */ 2 | 3 | @font-face { 4 | font-family: 'Material Icons Outlined'; 5 | font-style: normal; 6 | font-weight: 400; 7 | src: url(@/assets/material-icons-outlined.woff2) format('woff2'); 8 | } 9 | 10 | @font-face { 11 | font-family: "JetBrains Mono Medium"; 12 | font-weight: 400; 13 | font-style: normal; 14 | font-display: auto; 15 | src: url(@/assets/JetBrainsMono-Medium.woff2) format("woff2"); 16 | } 17 | 18 | .material-icons-outlined { 19 | font-family: 'Material Icons Outlined'; 20 | font-weight: normal; 21 | font-style: normal; 22 | font-size: 24px; 23 | line-height: 1; 24 | letter-spacing: normal; 25 | text-transform: none; 26 | display: inline-block; 27 | white-space: nowrap; 28 | word-wrap: normal; 29 | direction: ltr; 30 | -webkit-font-feature-settings: 'liga'; 31 | -webkit-font-smoothing: antialiased; 32 | } 33 | 34 | html { 35 | overflow: hidden; 36 | } 37 | 38 | * { 39 | -webkit-user-drag: none; 40 | /* iOS Safari */ 41 | -khtml-user-drag: none; 42 | /* Konqueror HTML */ 43 | -moz-user-drag: none; 44 | /* Old versions of Firefox */ 45 | -o-user-drag: none; 46 | /* Internet Explorer/Edge */ 47 | user-drag: none; 48 | /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ 49 | 50 | -webkit-touch-callout: none; 51 | /* iOS Safari */ 52 | -webkit-user-select: none; 53 | /* Safari */ 54 | -khtml-user-select: none; 55 | /* Konqueror HTML */ 56 | -moz-user-select: none; 57 | /* Old versions of Firefox */ 58 | -ms-user-select: none; 59 | /* Internet Explorer/Edge */ 60 | user-select: none; 61 | /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ 62 | } 63 | 64 | a { 65 | cursor: pointer; 66 | } 67 | 68 | .vertical-btn { 69 | display: flex; 70 | flex-direction: column; 71 | align-items: center; 72 | } 73 | 74 | .right { 75 | float: right; 76 | } 77 | 78 | .left { 79 | float: left; 80 | } 81 | 82 | .listitem-media-thumb { 83 | height: 50px; 84 | width: 50px; 85 | margin-right: 10px; 86 | align-content: center; 87 | } 88 | 89 | 90 | .line-clamp-1 { 91 | white-space: nowrap; 92 | text-overflow: ellipsis; 93 | overflow: hidden; 94 | } 95 | 96 | .line-clamp-2 { 97 | white-space: pre-line; 98 | overflow: hidden; 99 | line-height: 1.5em; 100 | height: 3em; 101 | word-wrap: break-word; 102 | box-sizing: border-box; 103 | display: -webkit-box; 104 | -webkit-line-clamp: 2; 105 | } 106 | 107 | .small-icon { 108 | font-size: 22px; 109 | height: 22px; 110 | width: 22px; 111 | } 112 | 113 | .small-btn { 114 | font-size: 40px; 115 | height: 40px; 116 | width: 40px; 117 | } 118 | 119 | .large-icon { 120 | font-size: 40px; 121 | height: 40px; 122 | width: 40px; 123 | } 124 | 125 | .large-btn { 126 | font-size: 50px; 127 | height: 50px; 128 | width: 50px; 129 | } 130 | 131 | .flicking-camera { 132 | display: flex !important; 133 | } 134 | 135 | 136 | 137 | h1 { 138 | font-size: 1.5rem; 139 | line-height: 2rem; 140 | font-weight: 500; 141 | } 142 | 143 | h2 { 144 | font-size: 1.25rem; 145 | line-height: 1.75rem; 146 | font-weight: 500; 147 | } 148 | 149 | h3 { 150 | font-size: 1.125rem; 151 | line-height: 1.5rem; 152 | font-weight: 500; 153 | } 154 | 155 | h4 { 156 | font-size: 1rem; 157 | line-height: 1.25rem; 158 | font-weight: 700; 159 | } 160 | 161 | h5 { 162 | font-size: 0.875rem; 163 | line-height: 1.25rem; 164 | font-weight: 500; 165 | } 166 | 167 | h6 { 168 | font-size: 0.75rem; 169 | line-height: 1rem; 170 | font-weight: 400; 171 | } 172 | 173 | .mh1 { 174 | margin-top: 1.5rem; 175 | margin-bottom: 1rem; 176 | } 177 | 178 | .mh2 { 179 | margin-top: 1.5rem; 180 | margin-bottom: 0.75rem; 181 | } 182 | 183 | .mh3 { 184 | margin-top: 1.5rem; 185 | margin-bottom: 0.5rem; 186 | } 187 | 188 | .mh4 { 189 | margin-top: 1.25rem; 190 | margin-bottom: 0.5rem; 191 | } 192 | 193 | .mh5 { 194 | margin-top: 1rem; 195 | margin-bottom: 0.25rem; 196 | } 197 | 198 | .mh6 { 199 | margin-top: 1rem; 200 | margin-bottom: 0; 201 | } 202 | 203 | /* Workaround for vuetify issue https://github.com/vuetifyjs/vuetify/issues/1197 */ 204 | html { 205 | overflow-y: hidden !important; 206 | } -------------------------------------------------------------------------------- /src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * src/styles/settings.scss 3 | * 4 | * Configures SASS variables and Vuetify overwrites 5 | */ 6 | 7 | // https://next.vuetifyjs.com/features/sass-variables/` 8 | @use 'vuetify/settings' with ( 9 | // vue listitem title overrides 10 | $list-item-title-font-size: 0.875rem, 11 | $list-item-title-line-height: 1.25rem, 12 | $list-item-title-font-weight: 500, 13 | 14 | // vue listitem subtitle overrides 15 | $list-item-subtitle-font-size:0.75rem, 16 | $list-item-subtitle-line-height: 1rem, 17 | $list-item-subtitle-font-weight: 400, 18 | 19 | $navigation-drawer-scrim-opacity: .85, 20 | $overlay-scrim-background: #000000b4, 21 | $overlay-opacity: .8, 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /src/views/AlbumDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 27 | 39 | 40 | 41 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 145 | -------------------------------------------------------------------------------- /src/views/ArtistDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 27 | 28 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 160 | -------------------------------------------------------------------------------- /src/views/AudiobookDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 63 | -------------------------------------------------------------------------------- /src/views/BrowseView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 | 20 | 44 | -------------------------------------------------------------------------------- /src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 56 | 57 | 63 | -------------------------------------------------------------------------------- /src/views/LibraryAlbums.vue: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 101 | -------------------------------------------------------------------------------- /src/views/LibraryArtists.vue: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 96 | -------------------------------------------------------------------------------- /src/views/LibraryAudiobooks.vue: -------------------------------------------------------------------------------- 1 | 2 | 30 | 31 | 32 | 97 | -------------------------------------------------------------------------------- /src/views/LibraryPlaylists.vue: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 22 | 141 | -------------------------------------------------------------------------------- /src/views/LibraryPodcasts.vue: -------------------------------------------------------------------------------- 1 | 2 | 30 | 31 | 32 | 98 | -------------------------------------------------------------------------------- /src/views/LibraryRadios.vue: -------------------------------------------------------------------------------- 1 | 2 | 37 | 38 | 39 | 40 | 104 | -------------------------------------------------------------------------------- /src/views/LibraryTracks.vue: -------------------------------------------------------------------------------- 1 | 2 | 38 | 39 | 40 | 41 | 108 | -------------------------------------------------------------------------------- /src/views/PlaylistDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | 30 | 31 | 32 | 33 | 34 | 90 | -------------------------------------------------------------------------------- /src/views/PodcastDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 27 | 28 | 29 | 30 | 31 | 32 | 126 | -------------------------------------------------------------------------------- /src/views/RadioDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 75 | -------------------------------------------------------------------------------- /src/views/TrackDetails.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 102 | -------------------------------------------------------------------------------- /src/views/settings/AddPlayerGroup.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ $t("settings.add_group_player") }} 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 29 | 30 | 35 | 43 | {{ $t("settings.dynamic_members.description") }} 44 | 45 | 46 | 53 | 54 | 64 | 65 | 71 | {{ $t("settings.save") }} 72 | 73 | 74 | 75 | 76 | {{ $t("close") }} 77 | 78 | 79 | 80 | 81 | 82 | 83 | 130 | -------------------------------------------------------------------------------- /src/views/settings/EditCoreConfig.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ 8 | $t("settings.setup_provider", [ 9 | api.providerManifests[config.domain].name, 10 | ]) 11 | }} 12 | 13 | 14 | {{ 15 | api.providerManifests[config.domain].description 16 | }} 18 | 21 | {{ $t("settings.codeowners") }}: {{ api.providerManifests[config.domain].codeowners.join(" / ") }} 23 | 24 | 25 | 28 | {{ $t("settings.need_help_setup_provider") }} 29 | {{ $t("settings.check_docs") }} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 51 | 52 | 58 | 59 | 60 | 61 | 62 | 63 | 141 | -------------------------------------------------------------------------------- /src/views/settings/FrontendConfig.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ $t("settings.frontend") }} 5 | 6 | 7 | {{ $t("settings.frontend_desc") }} 8 | 9 | 10 | 11 | 12 | 19 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 171 | -------------------------------------------------------------------------------- /src/views/settings/Settings.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 74 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module "*.vue" { 5 | import type { DefineComponent } from "vue"; 6 | // eslint-disable-next-line @typescript-eslint/ban-types 7 | const component: DefineComponent<{}, {}, unknown>; 8 | export default component; 9 | } 10 | 11 | declare module "vuetify/lib/util/colors"; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | "experimentalDecorators": true, 16 | "sourceMap": true, 17 | "noEmit": true, 18 | "types": ["vuetify", "node"], 19 | "forceConsistentCasingInFileNames": true, 20 | "paths": { 21 | "@/*": ["src/*"], 22 | "@plugins/*": ["src/plugins/*"], 23 | "@components/*": ["src/components/*"], 24 | "@views/*": ["src/views/*"], 25 | "@layout/*": ["src/layouts/*"], 26 | } 27 | }, 28 | "include": [ 29 | "src/**/*.ts", 30 | "src/**/*.tsx", 31 | "src/**/*.d.ts", 32 | "src/**/*.vue", 33 | "tests/**/*.ts", 34 | "tests/**/*.tsx" 35 | ], 36 | "references": [{ "path": "./tsconfig.node.json" }], 37 | "exclude": ["node_modules"] 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true, 8 | "forceConsistentCasingInFileNames": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | import vuetify, { transformAssetUrls } from "vite-plugin-vuetify"; 5 | import { VitePWA } from "vite-plugin-pwa"; 6 | import { fileURLToPath, URL } from "node:url"; 7 | import webfontDownload from "vite-plugin-webfont-dl"; 8 | import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"; 9 | import svgLoader from "vite-svg-loader"; 10 | import path from "path"; 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | base: "./", 15 | plugins: [ 16 | vue({ 17 | template: { transformAssetUrls }, 18 | }), 19 | webfontDownload([ 20 | "https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900&display=swap", 21 | ]), 22 | vuetify({ 23 | autoImport: true, 24 | styles: { 25 | configFile: "src/styles/settings.scss", 26 | }, 27 | }), 28 | VitePWA({ 29 | includeAssets: [ 30 | "favicon.svg", 31 | "favicon.ico", 32 | "robots.txt", 33 | "apple-touch-icon.png", 34 | ], 35 | manifest: { 36 | name: "Music Assistant", 37 | short_name: "Music Assistant", 38 | description: 39 | "Music Assistant is a free, opensource Media library manager that connects to your streaming services and a wide range of connected speakers.", 40 | theme_color: "#424242", 41 | icons: [ 42 | { 43 | src: "pwa-192x192.png", 44 | sizes: "192x192", 45 | type: "image/png", 46 | }, 47 | { 48 | src: "pwa-512x512.png", 49 | sizes: "512x512", 50 | type: "image/png", 51 | }, 52 | { 53 | src: "pwa-512x512.png", 54 | sizes: "512x512", 55 | type: "image/png", 56 | purpose: "any maskable", 57 | }, 58 | ], 59 | }, 60 | }), 61 | VueI18nPlugin({ 62 | include: [path.resolve(__dirname, "./src/translations/**")], 63 | }), 64 | ], 65 | define: { "process.env": {} }, 66 | resolve: { 67 | alias: { 68 | "@": fileURLToPath(new URL("./src", import.meta.url)), 69 | }, 70 | extensions: [".js", ".json", ".jsx", ".mjs", ".ts", ".tsx", ".vue"], 71 | }, 72 | server: { 73 | port: 3000, 74 | host: true, 75 | }, 76 | build: { 77 | outDir: "./music_assistant_frontend", 78 | }, 79 | }); 80 | --------------------------------------------------------------------------------