├── docker ├── env.js.template ├── docker-entrypoint.sh ├── Dockerfile └── nginx.conf ├── public ├── _redirects ├── manifest.webmanifest └── icon.svg ├── screenshots ├── album.png ├── artist.png ├── album-list.png └── artist-list.png ├── src ├── app │ ├── layout │ │ ├── Fullscreen.vue │ │ └── Default.vue │ ├── App.vue │ ├── ErrorToast.vue │ ├── Logo.vue │ ├── About.vue │ ├── SidebarNav.vue │ ├── Sidebar.vue │ └── TopNav.vue ├── global.d.ts ├── library │ ├── track │ │ ├── BaseTable.vue │ │ ├── CellTrackNumber.vue │ │ ├── BaseTableHead.vue │ │ ├── CellAlbum.vue │ │ ├── CellArtist.vue │ │ ├── CellDuration.vue │ │ ├── CellTitle.vue │ │ ├── CellActions.vue │ │ └── TrackList.vue │ ├── search │ │ ├── SearchForm.vue │ │ └── SearchResult.vue │ ├── artist │ │ ├── ArtistTracks.vue │ │ ├── ArtistList.vue │ │ ├── ArtistLibrary.vue │ │ └── ArtistDetails.vue │ ├── favourite │ │ ├── store.ts │ │ └── Favourites.vue │ ├── playlist │ │ ├── CreatePlaylistModal.vue │ │ ├── store.ts │ │ ├── PlaylistNav.vue │ │ ├── PlaylistLibrary.vue │ │ └── Playlist.vue │ ├── podcast │ │ ├── AddPodcastModal.vue │ │ ├── PodcastLibrary.vue │ │ └── PodcastDetails.vue │ ├── genre │ │ ├── GenreDetails.vue │ │ └── GenreLibrary.vue │ ├── album │ │ ├── AlbumLibrary.vue │ │ ├── AlbumList.vue │ │ └── AlbumDetails.vue │ ├── radio │ │ └── RadioStations.vue │ └── file │ │ └── Files.vue ├── shared │ ├── config.ts │ ├── components │ │ ├── ContentLoader.vue │ │ ├── ExternalLink.vue │ │ ├── EmptyIndicator.vue │ │ ├── IconReplayGain.vue │ │ ├── IconReplayGainAlbum.vue │ │ ├── IconReplayGainTrack.vue │ │ ├── Avatar.vue │ │ ├── OverflowMenu.vue │ │ ├── SwitchInput.vue │ │ ├── InfiniteList.vue │ │ ├── DropdownItem.vue │ │ ├── index.ts │ │ ├── IconLastFm.vue │ │ ├── Tile.vue │ │ ├── EditModal.vue │ │ ├── Tiles.vue │ │ ├── Hero.vue │ │ ├── InfiniteLoader.vue │ │ ├── ContextMenu.vue │ │ ├── OverflowFade.vue │ │ ├── Slider.vue │ │ ├── Dropdown.vue │ │ ├── IconMusicBrainz.vue │ │ └── Icon.vue │ ├── index.ts │ ├── store.ts │ ├── assets │ │ └── fallback.svg │ ├── compat.ts │ ├── utils.ts │ └── router.ts ├── style │ ├── nav-underlined.scss │ ├── table.scss │ └── main.scss ├── main.ts ├── discover │ └── Discover.vue ├── player │ ├── ProgressBar.vue │ ├── Queue.vue │ ├── audio.ts │ ├── Player.vue │ └── store.ts └── auth │ ├── Login.vue │ └── service.ts ├── .editorconfig ├── .gitignore ├── .github └── workflows │ ├── pr.yml │ └── ci.yml ├── index.html ├── tsconfig.json ├── vite.config.mjs ├── .eslintrc.js ├── package.json └── README.md /docker/env.js.template: -------------------------------------------------------------------------------- 1 | window.env = { 2 | SERVER_URL: "$SERVER_URL", 3 | } 4 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /api/* http://demo.subsonic.org/:splat 200 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /screenshots/album.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamland/airsonic-refix/HEAD/screenshots/album.png -------------------------------------------------------------------------------- /screenshots/artist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamland/airsonic-refix/HEAD/screenshots/artist.png -------------------------------------------------------------------------------- /screenshots/album-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamland/airsonic-refix/HEAD/screenshots/album-list.png -------------------------------------------------------------------------------- /screenshots/artist-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamland/airsonic-refix/HEAD/screenshots/artist-list.png -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | envsubst < env.js.template > /var/www/html/env.js 4 | exec "$@" 5 | -------------------------------------------------------------------------------- /src/app/layout/Fullscreen.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg'; 2 | declare module 'md5-es'; 3 | declare module 'vue-slider-component'; 4 | declare module 'icecast-metadata-stats'; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | max_line_length = 100 -------------------------------------------------------------------------------- /src/library/track/BaseTable.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/shared/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | serverUrl: string 3 | } 4 | 5 | const env = (window as any).env 6 | 7 | export const config: Config = { 8 | serverUrl: env?.SERVER_URL || '', 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/components/ContentLoader.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/library/track/CellTrackNumber.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/shared/components/ExternalLink.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/library/track/BaseTableHead.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Airsonic (refix)", 3 | "short_name": "Airsonic (refix)", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "theme_color": "#000", 7 | "background_color": "#000", 8 | "icons": [ 9 | { 10 | "src": "./icon.svg", 11 | "type": "image/svg+xml", 12 | "sizes": "any", 13 | "purpose": "any" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | EXPOSE 80 4 | 5 | RUN apk add gettext 6 | COPY docker/nginx.conf /etc/nginx/conf.d/default.conf 7 | COPY docker/env.js.template /env.js.template 8 | COPY docker/docker-entrypoint.sh /docker-entrypoint.sh 9 | RUN chmod +x /docker-entrypoint.sh 10 | COPY dist/ /var/www/html/ 11 | 12 | ENTRYPOINT ["/docker-entrypoint.sh"] 13 | CMD ["nginx", "-g", "daemon off;"] 14 | -------------------------------------------------------------------------------- /src/library/track/CellAlbum.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/shared/components/EmptyIndicator.vue: -------------------------------------------------------------------------------- 1 | 11 | 17 | -------------------------------------------------------------------------------- /src/shared/components/IconReplayGain.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | gzip on; 2 | gzip_proxied any; 3 | gzip_vary on; 4 | gzip_buffers 16 8k; 5 | gzip_types *; 6 | 7 | server { 8 | listen 80; 9 | server_name localhost; 10 | root /var/www/html; 11 | 12 | location / { 13 | root /var/www/html; 14 | try_files $uri /index.html; 15 | } 16 | 17 | location = /index.html { 18 | add_header 'Cache-Control' 'no-cache'; 19 | } 20 | 21 | location = /env.js { 22 | add_header 'Cache-Control' 'no-cache'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/library/track/CellArtist.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/library/track/CellDuration.vue: -------------------------------------------------------------------------------- 1 | 6 | 21 | -------------------------------------------------------------------------------- /src/shared/components/IconReplayGainAlbum.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/shared/components/IconReplayGainTrack.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/library/search/SearchForm.vue: -------------------------------------------------------------------------------- 1 | 9 | 25 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | import { API } from '@/shared/api' 2 | import { inject } from 'vue' 3 | import { AuthService } from '@/auth/service' 4 | import { App, Plugin } from '@/shared/compat' 5 | 6 | const apiSymbol = Symbol('') 7 | 8 | export function useApi(): API { 9 | return inject(apiSymbol) as API 10 | } 11 | 12 | export function createApi(auth: AuthService): API & Plugin { 13 | const instance = new API(auth) 14 | return Object.assign(instance, { 15 | install: (app: App) => { 16 | app.config.globalProperties.$api = instance 17 | app.provide(apiSymbol, instance) 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/style/nav-underlined.scss: -------------------------------------------------------------------------------- 1 | ul.nav-underlined { 2 | white-space: nowrap; 3 | overflow-x: auto; 4 | scrollbar-width: none; 5 | padding-left: 0; 6 | margin-bottom: 0; 7 | 8 | li { 9 | display: inline-block; 10 | float: none; 11 | a { 12 | display: block; 13 | padding: 0.5rem 1rem; 14 | } 15 | a:hover, a:focus { 16 | text-decoration: none; 17 | border-bottom: 2px solid rgba(#fff, .25); 18 | } 19 | a.active { 20 | border-bottom: 2px solid; 21 | } 22 | a.active:hover { 23 | color: var(--bs-primary); 24 | } 25 | } 26 | } 27 | 28 | ul.nav-underlined::-webkit-scrollbar { 29 | display: none; 30 | } 31 | -------------------------------------------------------------------------------- /src/shared/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 6 | 28 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | on: 3 | - pull_request 4 | 5 | env: 6 | IMAGE: ${{ github.repository }} 7 | VERSION: ${{ github.sha }} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: '20.x' 18 | 19 | - name: Install dependencies 20 | run: yarn install 21 | 22 | - name: Build 23 | run: | 24 | export VITE_BUILD=$VERSION 25 | export VITE_BUILD_DATE=$(date --iso-8601) 26 | yarn build 27 | 28 | - name: Build docker image 29 | run: docker build -t $IMAGE:$VERSION -f docker/Dockerfile . 30 | -------------------------------------------------------------------------------- /src/library/track/CellTitle.vue: -------------------------------------------------------------------------------- 1 | 12 | 25 | -------------------------------------------------------------------------------- /src/shared/components/OverflowMenu.vue: -------------------------------------------------------------------------------- 1 | 15 | 26 | -------------------------------------------------------------------------------- /src/app/layout/Default.vue: -------------------------------------------------------------------------------- 1 | 13 | 27 | 33 | -------------------------------------------------------------------------------- /src/shared/components/SwitchInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 29 | -------------------------------------------------------------------------------- /src/app/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Airsonic (refix) 12 | 13 | 14 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "skipLibCheck": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "useDefineForClassFields": true, 13 | "sourceMap": true, 14 | "baseUrl": "./src", 15 | "types": ["vite/client"], 16 | "paths": { 17 | "@/*": ["./*"] 18 | }, 19 | "lib": [ 20 | "esnext", 21 | "dom", 22 | "dom.iterable", 23 | "scripthost" 24 | ] 25 | }, 26 | "vueCompilerOptions": { 27 | "skipTemplateCodegen": true 28 | }, 29 | "include": [ 30 | "src/**/*.ts", 31 | "src/**/*.vue", 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/shared/components/InfiniteList.vue: -------------------------------------------------------------------------------- 1 | 7 | 35 | -------------------------------------------------------------------------------- /src/shared/components/DropdownItem.vue: -------------------------------------------------------------------------------- 1 | 20 | 32 | 37 | -------------------------------------------------------------------------------- /src/shared/components/index.ts: -------------------------------------------------------------------------------- 1 | import Avatar from './Avatar.vue' 2 | import ContentLoader from './ContentLoader.vue' 3 | import ContextMenu from '@/shared/components/ContextMenu.vue' 4 | import Dropdown from '@/shared/components/Dropdown.vue' 5 | import DropdownItem from '@/shared/components/DropdownItem.vue' 6 | import EmptyIndicator from './EmptyIndicator.vue' 7 | import ExternalLink from './ExternalLink.vue' 8 | import Hero from './Hero.vue' 9 | import Icon from './Icon.vue' 10 | import InfiniteLoader from './InfiniteLoader.vue' 11 | import OverflowMenu from './OverflowMenu.vue' 12 | import Slider from './Slider.vue' 13 | import Tiles from './Tiles.vue' 14 | import Tile from './Tile.vue' 15 | import { 16 | BButton, 17 | BModal, 18 | } from 'bootstrap-vue' 19 | 20 | export const components = { 21 | Avatar, 22 | BButton, 23 | BModal, 24 | ContentLoader, 25 | ContextMenu, 26 | Dropdown, 27 | DropdownItem, 28 | EmptyIndicator, 29 | ExternalLink, 30 | Hero, 31 | Icon, 32 | InfiniteLoader, 33 | OverflowMenu, 34 | Slider, 35 | Tile, 36 | Tiles, 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useLocalStorage } from '@vueuse/core' 3 | 4 | export const useMainStore = defineStore('main', { 5 | state: () => ({ 6 | isLoggedIn: false, 7 | username: null as null | string, 8 | server: null as null | string, 9 | error: null as null | Error, 10 | menuVisible: false, 11 | artistAlbumSortOrder: useLocalStorage<'desc' | 'asc'>('settings.artistAlbumSortOrder', 'desc') 12 | }), 13 | actions: { 14 | setError(error: Error) { 15 | this.error = error 16 | }, 17 | clearError() { 18 | this.error = null 19 | }, 20 | setLoginSuccess(username: string, server: string) { 21 | this.isLoggedIn = true 22 | this.username = username 23 | this.server = server 24 | }, 25 | showMenu() { 26 | this.menuVisible = true 27 | }, 28 | hideMenu() { 29 | this.menuVisible = false 30 | }, 31 | toggleArtistAlbumSortOrder() { 32 | this.artistAlbumSortOrder = this.artistAlbumSortOrder === 'asc' ? 'desc' : 'asc' 33 | } 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue2' 3 | import { fileURLToPath, URL } from 'node:url' 4 | import autoprefixer from 'autoprefixer' 5 | import checker from 'vite-plugin-checker' 6 | import bundleAnalyzer from 'rollup-plugin-bundle-analyzer' 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | vue(), 11 | checker({ 12 | vueTsc: true, 13 | eslint: { 14 | lintCommand: 'eslint . --ext .vue,.ts,.js --ignore-path .gitignore', 15 | }, 16 | overlay: { 17 | initialIsOpen: 'error', 18 | } 19 | }), 20 | bundleAnalyzer({ 21 | analyzerMode: 'static', 22 | reportFilename: 'report.html' 23 | }), 24 | ], 25 | resolve: { 26 | alias: { 27 | '@': fileURLToPath(new URL('./src', import.meta.url)) 28 | } 29 | }, 30 | css: { 31 | preprocessorOptions: { 32 | scss: { 33 | silenceDeprecations: ['mixed-decls', 'color-functions', 'global-builtin', 'import'] 34 | } 35 | }, 36 | postcss: { 37 | plugins: [ 38 | autoprefixer() 39 | ] 40 | } 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /src/library/artist/ArtistTracks.vue: -------------------------------------------------------------------------------- 1 | 7 | 43 | -------------------------------------------------------------------------------- /src/shared/components/IconLastFm.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/shared/components/Tile.vue: -------------------------------------------------------------------------------- 1 | 28 | 41 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/recommended', 8 | 'eslint:recommended', 9 | '@vue/standard', 10 | '@vue/typescript', 11 | '@vue/typescript/recommended' 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2020 15 | }, 16 | rules: { 17 | 'vue/script-indent': ['error', 2, { baseIndent: 1 }], 18 | 'vue/no-unused-components': 'warn', 19 | 'vue/component-tags-order': 'error', 20 | 'vue/valid-v-slot': 'off', // Bug: https://github.com/vuejs/eslint-plugin-vue/issues/1229 21 | 'vue/max-attributes-per-line': 'off', 22 | 'vue/html-closing-bracket-newline': 'off', 23 | 'vue/multi-word-component-names': 'off', 24 | 'vue/first-attribute-linebreak': 'off', 25 | 'no-console': 'off', 26 | 'no-debugger': 'warn', 27 | 'no-empty-pattern': 'off', 28 | 'comma-dangle': 'off', 29 | 'space-before-function-paren': ['error', 'never'], 30 | '@typescript-eslint/explicit-module-boundary-types': 'off', 31 | '@typescript-eslint/no-explicit-any': 'off', 32 | '@typescript-eslint/no-non-null-assertion': 'off', 33 | }, 34 | overrides: [ 35 | { 36 | files: ['*.vue'], 37 | rules: { 38 | indent: 'off' 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/shared/components/EditModal.vue: -------------------------------------------------------------------------------- 1 | 16 | 49 | -------------------------------------------------------------------------------- /src/library/artist/ArtistList.vue: -------------------------------------------------------------------------------- 1 | 23 | 48 | -------------------------------------------------------------------------------- /src/shared/components/Tiles.vue: -------------------------------------------------------------------------------- 1 | 6 | 16 | 47 | -------------------------------------------------------------------------------- /src/library/favourite/store.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { defineStore } from 'pinia' 3 | 4 | type MediaType = 'track' | 'album' | 'artist' 5 | 6 | export const useFavouriteStore = defineStore('favourite', { 7 | state: () => ({ 8 | albums: {} as any, 9 | artists: {} as any, 10 | tracks: {} as any, 11 | }), 12 | actions: { 13 | load() { 14 | return this.api.getFavourites().then(result => { 15 | this.albums = createIdMap(result.albums) 16 | this.artists = createIdMap(result.artists) 17 | this.tracks = createIdMap(result.tracks) 18 | }) 19 | }, 20 | toggle(type: MediaType, id: string) { 21 | const field = getTypeKey(type) 22 | if (this[field][id]) { 23 | Vue.delete(this[field], id) 24 | return this.api.removeFavourite(id, type) 25 | } else { 26 | Vue.set(this[field], id, true) 27 | return this.api.addFavourite(id, type) 28 | } 29 | }, 30 | }, 31 | }) 32 | 33 | function createIdMap(items: [{ id: string }]) { 34 | return Object.assign({}, ...items.map((item) => ({ [item.id]: true }))) 35 | } 36 | 37 | function getTypeKey(type: string): 'albums' | 'artists' |'tracks' { 38 | switch (type) { 39 | case 'album': return 'albums' 40 | case 'artist': return 'artists' 41 | case 'track': return 'tracks' 42 | default: throw new Error(`unknown favourite type '${type}'`) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/ErrorToast.vue: -------------------------------------------------------------------------------- 1 | 20 | 34 | 46 | -------------------------------------------------------------------------------- /src/library/playlist/CreatePlaylistModal.vue: -------------------------------------------------------------------------------- 1 | 18 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airsonic-refix", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@iconify-icons/bi": "^1.2.2", 12 | "@vueuse/core": "^11.1.0", 13 | "bootstrap": "^5.3.3", 14 | "bootstrap-vue": "^2.23.1", 15 | "icecast-metadata-stats": "^0.1.1", 16 | "lodash-es": "^4.17.21", 17 | "md5-es": "^1.8.2", 18 | "pinia": "^2.0.28", 19 | "vue": "^2.7.0", 20 | "vue-router": "^3.5.2", 21 | "vue-slider-component": "^3.2.22" 22 | }, 23 | "devDependencies": { 24 | "@types/lodash-es": "^4.17.6", 25 | "@typescript-eslint/eslint-plugin": "^5.18.0", 26 | "@typescript-eslint/parser": "^5.18.0", 27 | "@vitejs/plugin-vue2": "^2.3.3", 28 | "@vue/eslint-config-standard": "^8.0.1", 29 | "@vue/eslint-config-typescript": "^11.0.2", 30 | "autoprefixer": "^10.4.20", 31 | "eslint": "^8.13.0", 32 | "eslint-plugin-import": "^2.24.2", 33 | "eslint-plugin-node": "^11.1.0", 34 | "eslint-plugin-promise": "^6.0.0", 35 | "eslint-plugin-standard": "^5.0.0", 36 | "eslint-plugin-vue": "^9.5.1", 37 | "rollup-plugin-bundle-analyzer": "^1.6.6", 38 | "sass": "^1.34.0", 39 | "typescript": "^5.0.4", 40 | "vite": "^7.0.0", 41 | "vite-plugin-checker": "^0.10.2", 42 | "vue-tsc": "^2.2.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/library/podcast/AddPodcastModal.vue: -------------------------------------------------------------------------------- 1 | 21 | 52 | -------------------------------------------------------------------------------- /src/style/table.scss: -------------------------------------------------------------------------------- 1 | table thead tr { 2 | font-size: 80%; 3 | text-transform: uppercase; 4 | color: var(--bs-secondary-color); 5 | } 6 | 7 | table.table { 8 | th { 9 | color: var(--bs-secondary-color); 10 | } 11 | tr { 12 | cursor: pointer; 13 | } 14 | tr.active { 15 | td, td a, td button.dropdown-toggle { 16 | color: var(--bs-primary); 17 | } 18 | } 19 | tr.disabled { 20 | cursor: default; 21 | td, td a, td button.dropdown-toggle { 22 | color: var(--bs-secondary-color); 23 | } 24 | button { 25 | cursor: default; 26 | } 27 | } 28 | } 29 | 30 | table.table-numbered { 31 | th:first-child, td:first-child { 32 | padding-left: 0; 33 | padding-right: 0; 34 | width: 26px; 35 | max-width: 26px; 36 | text-align: center; 37 | color: var(--bs-secondary-color); 38 | } 39 | tr td:first-child button { 40 | border: none; 41 | background: none; 42 | color: inherit; 43 | font: inherit; 44 | outline: inherit; 45 | .number { display: inline; white-space: nowrap; } 46 | .icon { display: none; } 47 | } 48 | tr.active td:first-child button { 49 | .number { display: none;} 50 | .icon { display: inline;} 51 | } 52 | tr:hover td:first-child button { 53 | .number { display: none; } 54 | .icon { display: inline; } 55 | } 56 | tr.disabled:hover td:first-child button { 57 | .number { display: inline;} 58 | .icon { display: none;} 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/shared/assets/fallback.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/library/artist/ArtistLibrary.vue: -------------------------------------------------------------------------------- 1 | 21 | 53 | -------------------------------------------------------------------------------- /src/shared/compat.ts: -------------------------------------------------------------------------------- 1 | import { VueConstructor } from 'vue/types/vue' 2 | import Vue from 'vue' 3 | 4 | export interface App { 5 | config: VueConstructor['config'] & {globalProperties: any} 6 | // eslint-disable-next-line no-use-before-define 7 | use(plugin: Plugin, options?: T): this 8 | component: VueConstructor['component'] 9 | provide(key: symbol | string, value: T): this 10 | mount: Vue['$mount'] 11 | // directive(name: string): Directive | undefined 12 | // directive(name: string, directive: Directive): this 13 | // unmount: Vue['$destroy'] 14 | } 15 | 16 | export interface Plugin { 17 | install: (app: App, options?: T) => void 18 | } 19 | 20 | export const createApp = (component: any, options: any): App => { 21 | const provide: Record = {} 22 | let vm = undefined as undefined | Vue 23 | return { 24 | use(plugin: Plugin, options?: T) { 25 | (plugin as any).install(this, options) 26 | return this 27 | }, 28 | config: Object.assign( 29 | Vue.config, 30 | { globalProperties: Vue.prototype } 31 | ), 32 | component: Vue.component.bind(Vue), 33 | provide(key: symbol | string, value: T) { 34 | provide[key as any] = value 35 | return this 36 | }, 37 | mount: (el, hydrating) => { 38 | if (!vm) { 39 | vm = new Vue({ 40 | ...options, 41 | provide, 42 | render: (h: any) => h(component), 43 | }) 44 | vm.$mount(el, hydrating) 45 | } 46 | return vm 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/shared/components/Hero.vue: -------------------------------------------------------------------------------- 1 | 11 | 26 | 55 | -------------------------------------------------------------------------------- /src/shared/components/InfiniteLoader.vue: -------------------------------------------------------------------------------- 1 | 6 | 57 | -------------------------------------------------------------------------------- /src/library/genre/GenreDetails.vue: -------------------------------------------------------------------------------- 1 | 28 | 54 | -------------------------------------------------------------------------------- /src/shared/components/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 13 | 59 | -------------------------------------------------------------------------------- /src/library/genre/GenreLibrary.vue: -------------------------------------------------------------------------------- 1 | 30 | 57 | -------------------------------------------------------------------------------- /src/shared/components/OverflowFade.vue: -------------------------------------------------------------------------------- 1 | 6 | 43 | 58 | -------------------------------------------------------------------------------- /src/app/Logo.vue: -------------------------------------------------------------------------------- 1 | 27 | 34 | -------------------------------------------------------------------------------- /src/library/playlist/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { Playlist } from '@/shared/api' 3 | import { orderBy } from 'lodash-es' 4 | 5 | export const usePlaylistStore = defineStore('playlist', { 6 | state: () => ({ 7 | playlists: null as null | Playlist[], 8 | }), 9 | actions: { 10 | load() { 11 | return this.api.getPlaylists().then(result => { 12 | this.playlists = orderBy(result, 'createdAt') 13 | }) 14 | }, 15 | create(name: string, tracks?: string[]) { 16 | return this.api.createPlaylist(name, tracks).then(result => { 17 | this.playlists = orderBy(result, 'createdAt') 18 | }) 19 | }, 20 | async update({ id, name, comment, isPublic }: Playlist) { 21 | const playlist = this.playlists?.find(x => x.id === id) 22 | if (playlist) { 23 | playlist.name = name 24 | playlist.comment = comment 25 | playlist.isPublic = isPublic 26 | await this.api.editPlaylist(id, name, comment, isPublic) 27 | } 28 | }, 29 | async addTracks(playlistId: string, trackIds: string[]) { 30 | const playlist = this.playlists?.find(x => x.id === playlistId) 31 | if (playlist) { 32 | await this.api.addToPlaylist(playlistId, trackIds) 33 | playlist.updatedAt = new Date().toISOString() 34 | playlist.trackCount = (playlist?.trackCount || 0) + trackIds.length 35 | } 36 | }, 37 | async removeTrack(playlistId: string, index: number) { 38 | const playlist = this.playlists?.find(x => x.id === playlistId) 39 | if (playlist) { 40 | await this.api.removeFromPlaylist(playlistId, index) 41 | playlist.updatedAt = new Date().toISOString() 42 | playlist.trackCount = (playlist?.trackCount || 0) - 1 43 | } 44 | }, 45 | async delete(id: string) { 46 | await this.api.deletePlaylist(id) 47 | this.playlists = this.playlists!.filter(p => p.id !== id) 48 | }, 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /src/app/About.vue: -------------------------------------------------------------------------------- 1 | 38 | 63 | -------------------------------------------------------------------------------- /src/library/album/AlbumLibrary.vue: -------------------------------------------------------------------------------- 1 | 15 | 68 | -------------------------------------------------------------------------------- /src/shared/components/Slider.vue: -------------------------------------------------------------------------------- 1 | 12 | 40 | 74 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import '@/style/main.scss' 2 | import Vue, { markRaw, watch } from 'vue' 3 | import Router from 'vue-router' 4 | import AppComponent from '@/app/App.vue' 5 | import { createApp } from '@/shared/compat' 6 | import { components } from '@/shared/components' 7 | import { setupRouter } from '@/shared/router' 8 | import { useMainStore } from '@/shared/store' 9 | import { API } from '@/shared/api' 10 | import { createAuth } from '@/auth/service' 11 | import { setupAudio, usePlayerStore } from './player/store' 12 | import { createApi } from '@/shared' 13 | import { createPinia, PiniaVuePlugin } from 'pinia' 14 | import { useFavouriteStore } from '@/library/favourite/store' 15 | import { usePlaylistStore } from '@/library/playlist/store' 16 | 17 | declare module 'vue/types/vue' { 18 | interface Vue { 19 | $api: API 20 | } 21 | } 22 | 23 | declare module 'pinia' { 24 | export interface PiniaCustomProperties { 25 | api: API; 26 | } 27 | } 28 | 29 | Vue.use(Router) 30 | Vue.use(PiniaVuePlugin) 31 | 32 | const auth = createAuth() 33 | const api = createApi(auth) 34 | const router = setupRouter(auth) 35 | 36 | const pinia = createPinia() 37 | .use(({ store }) => { 38 | store.api = markRaw(api) 39 | }) 40 | 41 | const mainStore = useMainStore(pinia) 42 | const playerStore = usePlayerStore(pinia) 43 | 44 | setupAudio(playerStore, mainStore, api) 45 | 46 | watch( 47 | () => mainStore.isLoggedIn, 48 | (value) => { 49 | if (value) { 50 | return Promise.all([ 51 | useFavouriteStore().load(), 52 | usePlaylistStore().load(), 53 | playerStore.loadQueue(), 54 | ]) 55 | } 56 | }) 57 | 58 | router.beforeEach((to, from, next) => { 59 | mainStore.clearError() 60 | next() 61 | }) 62 | 63 | const app = createApp(AppComponent, { router, pinia, store: playerStore }) 64 | 65 | app.config.errorHandler = (err: Error) => { 66 | // eslint-disable-next-line 67 | console.error(err) 68 | mainStore.setError(err) 69 | } 70 | 71 | app.use(auth) 72 | app.use(api) 73 | 74 | Object.entries(components).forEach(([key, value]) => { 75 | app.component(key, value as any) 76 | }) 77 | 78 | app.mount('#app') 79 | -------------------------------------------------------------------------------- /src/app/SidebarNav.vue: -------------------------------------------------------------------------------- 1 | 57 | 75 | -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import MD5 from 'md5-es' 2 | import { Track } from '@/shared/api' 3 | 4 | export function randomString(): string { 5 | let arr = new Uint8Array(16) 6 | window.crypto.getRandomValues(arr) 7 | const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 8 | arr = arr.map(x => validChars.charCodeAt(x % validChars.length)) 9 | return String.fromCharCode.apply(null, Array.from(arr)) 10 | } 11 | 12 | export function shuffle(list: T[], moveFirst?: number): void { 13 | if (moveFirst !== undefined) { 14 | [list[0], list[moveFirst]] = [list[moveFirst], list[0]] 15 | } 16 | const start = moveFirst !== undefined ? 1 : 0 17 | const end = list.length - 1 18 | for (let i = end; i > start; i--) { 19 | const j = Math.floor(Math.random() * (i - start + 1) + start); 20 | [list[i], list[j]] = [list[j], list[i]] 21 | } 22 | } 23 | 24 | export function shuffled(list: T[], moveFirst?: number): T[] { 25 | list = [...list] 26 | shuffle(list, moveFirst) 27 | return list 28 | } 29 | 30 | export function md5(str: string): string { 31 | return MD5.hash(str) 32 | } 33 | 34 | export function trackListEquals(a: Track[], b: Track[]): boolean { 35 | if (a.length !== b.length) { 36 | return false 37 | } 38 | for (let i = 0; i < a.length; i++) { 39 | if (a[i].id !== b[i].id) { 40 | return false 41 | } 42 | } 43 | return true 44 | } 45 | 46 | export function formatArtists(artists: { name: string }[]): string { 47 | return artists.map(ar => ar.name).join(', ') 48 | } 49 | 50 | export function toQueryString(params: Record): string { 51 | const list = Object.entries(params) 52 | .filter(([, value]) => value !== undefined) 53 | .map(([key, value]) => Array.isArray(value) ? value.map((x) => [key, x]) : [[key, value]]) 54 | .flat() 55 | return new URLSearchParams(list).toString() 56 | } 57 | 58 | export function formatDuration(value: number): string { 59 | if (!isFinite(value)) { 60 | return '∞' 61 | } 62 | const minutes = Math.floor(value / 60) 63 | const seconds = Math.floor(value % 60) 64 | return (minutes < 10 ? '0' : '') + minutes + ':' + (seconds < 10 ? '0' : '') + seconds 65 | } 66 | 67 | export function sleep(ms: number) { 68 | return new Promise(resolve => setTimeout(resolve, ms)) 69 | } 70 | -------------------------------------------------------------------------------- /src/shared/components/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 19 | 66 | 86 | -------------------------------------------------------------------------------- /src/app/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 18 | 34 | 92 | -------------------------------------------------------------------------------- /src/library/favourite/Favourites.vue: -------------------------------------------------------------------------------- 1 | 36 | 75 | -------------------------------------------------------------------------------- /src/library/playlist/PlaylistNav.vue: -------------------------------------------------------------------------------- 1 | 28 | 80 | -------------------------------------------------------------------------------- /src/app/TopNav.vue: -------------------------------------------------------------------------------- 1 | 42 | 91 | -------------------------------------------------------------------------------- /src/shared/components/IconMusicBrainz.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Airsonic (refix) UI 2 | 3 | [![Build](https://img.shields.io/github/actions/workflow/status/tamland/airsonic-refix/ci.yml?style=flat-square)](https://github.com/tamland/airsonic-refix/actions) 4 | [![Docker Pulls](https://img.shields.io/docker/pulls/tamland/airsonic-refix?branch=master&style=flat-square)](https://hub.docker.com/r/tamland/airsonic-refix) 5 | 6 | Modern responsive web frontend for [airsonic-advanced](https://github.com/airsonic-advanced/airsonic-advanced), [navidrome](https://github.com/navidrome/navidrome), 7 | [gonic](https://github.com/sentriz/gonic) and other [subsonic](https://github.com/topics/subsonic) compatible music servers. 8 | 9 | ## Features 10 | - Responsive UI for desktop and mobile 11 | - Browse library for albums, artist, genres 12 | - Playback with persistent queue, repeat & shuffle 13 | - MediaSession integration 14 | - View, create, and edit playlists with drag and drop 15 | - Built-in 'random' playlist 16 | - Search 17 | - Favourites 18 | - Internet radio 19 | - Podcasts 20 | 21 | ## [Live demo](https://airsonic-refix.netlify.app) 22 | 23 | Enter the URL and credentials for your subsonic compatible server, or use one of the following public demo servers: 24 | 25 | **Subsonic** 26 | Server: `https://airsonic-refix.netlify.app/api` 27 | Username: `guest4`, `guest5`, `guest6` etc. 28 | Password:`guest` 29 | 30 | **Navidrome** 31 | Server: `https://demo.navidrome.org` 32 | Username: `demo` 33 | Password:`demo` 34 | 35 | 36 | **Note**: if the server is using http only you must allow mixed content in your browser otherwise login will not work. 37 | 38 | ## Screenshots 39 | 40 | ![Screenshot](screenshots/album.png) 41 | 42 | ![Screenshot](screenshots/album-list.png) 43 | 44 | ![Screenshot](screenshots/artist.png) 45 | 46 | ![Screenshot](screenshots/artist-list.png) 47 | 48 | ## Install 49 | 50 | ### Docker 51 | 52 | ``` 53 | $ docker run -d -p 8080:80 tamland/airsonic-refix:latest 54 | ``` 55 | 56 | You can now access the application at http://localhost:8080/ 57 | 58 | Environment variables: 59 | - `SERVER_URL` (Optional): The backend server URL. When set the server input on the login page will not be displayed. 60 | 61 | 62 | ### Pre-built bundle 63 | 64 | Pre-built bundles can be found in [Releases](https://github.com/tamland/airsonic-refix/releases). Download/extract artifact and serve with any web server such as nginx or apache. 65 | 66 | ### Build from source 67 | 68 | ``` 69 | $ yarn install 70 | $ yarn build 71 | ``` 72 | 73 | Bundle can be found in the `dist` folder. 74 | 75 | Build docker image: 76 | 77 | ``` 78 | $ docker build -f docker/Dockerfile . 79 | ``` 80 | 81 | ## Develop 82 | 83 | ``` 84 | $ yarn install 85 | $ yarn dev 86 | ``` 87 | 88 | ## OpenSubsonic support 89 | 90 | - HTTP form POST extension 91 | - Multiple artists/genres 92 | 93 | ## License 94 | 95 | Licensed under the [AGPLv3](LICENSE) license. 96 | -------------------------------------------------------------------------------- /src/library/album/AlbumList.vue: -------------------------------------------------------------------------------- 1 | 40 | 83 | -------------------------------------------------------------------------------- /src/library/track/CellActions.vue: -------------------------------------------------------------------------------- 1 | 41 | 89 | -------------------------------------------------------------------------------- /src/library/playlist/PlaylistLibrary.vue: -------------------------------------------------------------------------------- 1 | 46 | 88 | -------------------------------------------------------------------------------- /src/library/track/TrackList.vue: -------------------------------------------------------------------------------- 1 | 34 | 92 | -------------------------------------------------------------------------------- /src/library/podcast/PodcastLibrary.vue: -------------------------------------------------------------------------------- 1 | 43 | 97 | -------------------------------------------------------------------------------- /src/discover/Discover.vue: -------------------------------------------------------------------------------- 1 | 42 | 97 | -------------------------------------------------------------------------------- /src/library/search/SearchResult.vue: -------------------------------------------------------------------------------- 1 | 38 | 106 | -------------------------------------------------------------------------------- /src/player/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 15 | 47 | 128 | -------------------------------------------------------------------------------- /src/library/radio/RadioStations.vue: -------------------------------------------------------------------------------- 1 | 47 | 104 | -------------------------------------------------------------------------------- /src/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 40 | 110 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | 5 | env: 6 | IMAGE: ${{ github.repository }} 7 | VERSION: ${{ github.sha }} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: '20.x' 18 | 19 | - name: Install dependencies 20 | run: yarn install 21 | 22 | - name: Build 23 | run: | 24 | export VITE_BUILD=$VERSION 25 | export VITE_BUILD_DATE=$(date --iso-8601) 26 | yarn build 27 | rm dist/report.html 28 | 29 | - name: Upload artifact 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: dist 33 | path: dist 34 | 35 | build_docker_image: 36 | runs-on: ubuntu-latest 37 | needs: build 38 | steps: 39 | - uses: actions/checkout@v2 40 | 41 | - name: Download artifact 42 | uses: actions/download-artifact@v4 43 | with: 44 | name: dist 45 | path: dist 46 | 47 | - name: Docker meta 48 | id: meta 49 | uses: docker/metadata-action@v3 50 | with: 51 | images: ${{ env.IMAGE }} 52 | tags: | 53 | type=sha 54 | flavor: | 55 | latest=${{ github.ref == 'refs/heads/master' }} 56 | 57 | - name: Set up Docker Buildx 58 | uses: docker/setup-buildx-action@v1 59 | 60 | - name: Login to DockerHub 61 | if: github.event_name != 'pull_request' 62 | uses: docker/login-action@v1 63 | with: 64 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 65 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 66 | 67 | - name: Build and push 68 | uses: docker/build-push-action@v2 69 | with: 70 | context: . 71 | platforms: linux/amd64,linux/arm64,linux/arm/v7 72 | push: ${{ github.event_name != 'pull_request' }} 73 | file: docker/Dockerfile 74 | tags: ${{ steps.meta.outputs.tags }} 75 | labels: ${{ steps.meta.outputs.labels }} 76 | 77 | release: 78 | runs-on: ubuntu-latest 79 | needs: build 80 | if: github.ref == 'refs/heads/master' 81 | steps: 82 | - name: Download artifact 83 | uses: actions/download-artifact@v4 84 | with: 85 | name: dist 86 | path: dist 87 | 88 | - name: Create release 89 | run: tar -C dist/ -czvf dist.tar.gz . 90 | 91 | - name: Upload release 92 | uses: softprops/action-gh-release@v2 93 | with: 94 | tag_name: sha-${{ github.sha }} 95 | files: dist.tar.gz 96 | 97 | preview: 98 | runs-on: ubuntu-latest 99 | needs: build 100 | if: github.ref != 'refs/heads/master' 101 | steps: 102 | - name: Download artifact 103 | uses: actions/download-artifact@v4 104 | with: 105 | name: dist 106 | path: dist 107 | 108 | - name: Deploy preview 109 | uses: netlify/actions/cli@master 110 | env: 111 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 112 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 113 | with: 114 | args: deploy --dir=dist 115 | 116 | deploy: 117 | runs-on: ubuntu-latest 118 | needs: build 119 | if: github.ref == 'refs/heads/master' 120 | steps: 121 | - name: Download artifact 122 | uses: actions/download-artifact@v4 123 | with: 124 | name: dist 125 | path: dist 126 | 127 | - name: Deploy site 128 | uses: netlify/actions/cli@master 129 | env: 130 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 131 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 132 | with: 133 | args: deploy --dir=dist --prod 134 | 135 | -------------------------------------------------------------------------------- /src/library/file/Files.vue: -------------------------------------------------------------------------------- 1 | 42 | 118 | -------------------------------------------------------------------------------- /src/style/main.scss: -------------------------------------------------------------------------------- 1 | $theme-elevation-0: hsl(0, 0%, 0%); 2 | $theme-elevation-1: hsl(0, 0%, 8%); 3 | $theme-elevation-2: hsl(0, 0%, 18%); 4 | $theme-text: #ccc; 5 | $theme-text-muted: #999; 6 | 7 | // Bootstrap overrides 8 | $primary: #09f; 9 | $danger: #ff4141; 10 | $secondary: $theme-elevation-2; 11 | 12 | $body-bg: $theme-elevation-0; 13 | $body-color: $theme-text; 14 | 15 | $grid-gutter-width: 2rem; 16 | 17 | $headings-color: #fff; 18 | 19 | $link-color: $theme-text; 20 | $link-decoration: none; 21 | $link-hover-decoration: underline; 22 | 23 | $text-muted: $theme-text-muted; 24 | $border-color: $theme-elevation-2; 25 | 26 | // Button 27 | .btn-primary { 28 | --bs-btn-color: #fff !important; 29 | --bs-btn-disabled-color: #fff !important; 30 | --bs-btn-active-color: #fff !important; 31 | --bs-btn-hover-color: #fff !important; 32 | } 33 | 34 | .btn-transparent { 35 | --bs-btn-color: $theme-text; 36 | --bs-btn-bg: transparent; 37 | --bs-btn-border-width: 0; 38 | 39 | --bs-btn-active-color: #fff; 40 | --bs-btn-active-border-color: transparent; 41 | 42 | --bs-btn-hover-color: #fff; 43 | --bs-btn-hover-bg: #{rgba(#fff, .12)}; 44 | 45 | --bs-btn-focus-shadow-rgb: 125, 125, 125; 46 | 47 | --bs-btn-disabled-bg: transparent; 48 | --bs-btn-disabled-border-color: transparent; 49 | } 50 | 51 | $btn-close-color: $theme-text; 52 | 53 | // Card 54 | $card-bg: $theme-elevation-1; 55 | $card-cap-bg: $theme-elevation-1; 56 | $card-spacer-y: 1.25rem; 57 | $card-spacer-x: 1.25rem; 58 | 59 | // Modal 60 | $modal-content-bg: $theme-elevation-1; 61 | $modal-content-border-color: $theme-elevation-2; 62 | .modal-backdrop { 63 | opacity: .5; 64 | } 65 | 66 | // Dropdown 67 | $dropdown-bg: $theme-elevation-1; 68 | $dropdown-link-color: $theme-text; 69 | $dropdown-link-hover-bg: $theme-elevation-2; 70 | $dropdown-link-hover-color: white; 71 | $dropdown-border-color: $theme-elevation-2; 72 | $dropdown-divider-bg: $theme-elevation-2; 73 | 74 | .dropdown-toggle.dropdown-toggle-no-caret::before, 75 | .dropdown-toggle.dropdown-toggle-no-caret::after { 76 | display: none !important; 77 | } 78 | .dropdown-menu { 79 | --bs-dropdown-link-active-bg: var(--bs-primary) !important; 80 | } 81 | 82 | // Popover 83 | $popover-bg: $theme-elevation-1; 84 | $popover-border-color: $theme-elevation-2; 85 | 86 | // Tooltip 87 | $tooltip-bg: $theme-elevation-1; 88 | $tooltip-color: $theme-text; 89 | 90 | .tooltip { 91 | position: absolute; 92 | } 93 | 94 | // Form 95 | $input-bg: $theme-elevation-2; 96 | $input-disabled-bg: $theme-elevation-1; 97 | $input-border-color: $theme-elevation-2; 98 | $input-color: $theme-text; 99 | $custom-range-track-height: 0.1rem; 100 | $custom-range-thumb-bg: $theme-text; 101 | $custom-range-track-bg: $theme-text-muted; 102 | $form-switch-color-dark: rgba(#fff, .25); 103 | $form-switch-bg-image: url("data:image/svg+xml,"); 104 | 105 | // List group 106 | $list-group-bg: rgba(#fff, 0); 107 | $list-group-hover-bg: rgba(#fff, .1); 108 | $list-group-color: $theme-text; 109 | $list-group-action-hover-color: $theme-text; 110 | $list-group-border-color: $theme-elevation-2; 111 | $list-group-action-active-bg: rgba(#fff, .1); 112 | 113 | // Offcanvas 114 | $offcanvas-bg-color:$theme-elevation-1; 115 | 116 | // Table 117 | $table-color: $theme-text; 118 | $table-bg: rgba(#fff, 0); 119 | $table-hover-bg: rgba(#fff, .1); 120 | 121 | $table-cell-padding-y: 0.75rem; 122 | $table-cell-padding-x: 0.75rem; 123 | @import './table'; 124 | 125 | .elevated { 126 | background-color: $theme-elevation-1; 127 | } 128 | 129 | .breadcrumb { 130 | background: none !important; 131 | padding: 0 !important; 132 | } 133 | 134 | a.active { 135 | color: var(--bs-primary); 136 | } 137 | 138 | .btn-link:focus { 139 | box-shadow: none !important; 140 | } 141 | 142 | @import './nav-underlined'; 143 | @import 'bootstrap/scss/bootstrap'; 144 | @import 'vue-slider-component/lib/theme/material'; 145 | -------------------------------------------------------------------------------- /src/shared/components/Icon.vue: -------------------------------------------------------------------------------- 1 | 18 | 103 | 114 | -------------------------------------------------------------------------------- /src/library/podcast/PodcastDetails.vue: -------------------------------------------------------------------------------- 1 | 48 | 122 | -------------------------------------------------------------------------------- /src/library/album/AlbumDetails.vue: -------------------------------------------------------------------------------- 1 | 77 | 139 | -------------------------------------------------------------------------------- /src/shared/router.ts: -------------------------------------------------------------------------------- 1 | import Router from 'vue-router' 2 | import Login from '@/auth/Login.vue' 3 | import Queue from '@/player/Queue.vue' 4 | import Discover from '@/discover/Discover.vue' 5 | import ArtistDetails from '@/library/artist/ArtistDetails.vue' 6 | import ArtistLibrary from '@/library/artist/ArtistLibrary.vue' 7 | import AlbumDetails from '@/library/album/AlbumDetails.vue' 8 | import AlbumLibrary from '@/library/album/AlbumLibrary.vue' 9 | import GenreDetails from '@/library/genre/GenreDetails.vue' 10 | import GenreLibrary from '@/library/genre/GenreLibrary.vue' 11 | import Favourites from '@/library/favourite/Favourites.vue' 12 | import RadioStations from '@/library/radio/RadioStations.vue' 13 | import PodcastDetails from '@/library/podcast/PodcastDetails.vue' 14 | import PodcastLibrary from '@/library/podcast/PodcastLibrary.vue' 15 | import Playlist from '@/library/playlist/Playlist.vue' 16 | import PlaylistLibrary from '@/library/playlist/PlaylistLibrary.vue' 17 | import SearchResult from '@/library/search/SearchResult.vue' 18 | import { AuthService } from '@/auth/service' 19 | import ArtistTracks from '@/library/artist/ArtistTracks.vue' 20 | import Files from '@/library/file/Files.vue' 21 | 22 | export function setupRouter(auth: AuthService) { 23 | const router = new Router({ 24 | mode: 'history', 25 | linkExactActiveClass: 'active', 26 | base: import.meta.env.BASE_URL, 27 | routes: [ 28 | { 29 | path: '/', 30 | name: 'home', 31 | component: Discover 32 | }, 33 | { 34 | name: 'login', 35 | path: '/login', 36 | component: Login, 37 | props: (route) => ({ 38 | returnTo: route.query.returnTo, 39 | }), 40 | meta: { 41 | layout: 'fullscreen' 42 | } 43 | }, 44 | { 45 | name: 'queue', 46 | path: '/queue', 47 | component: Queue, 48 | }, 49 | { 50 | name: 'albums-default', 51 | path: '/albums', 52 | redirect: ({ 53 | name: 'albums', 54 | params: { sort: 'recently-added' } 55 | }), 56 | }, 57 | { 58 | name: 'albums', 59 | path: '/albums/:sort', 60 | component: AlbumLibrary, 61 | props: true 62 | }, 63 | { 64 | name: 'album', 65 | path: '/albums/id/:id', 66 | component: AlbumDetails, 67 | props: true, 68 | }, 69 | { 70 | name: 'artists', 71 | path: '/artists/:sort?', 72 | component: ArtistLibrary, 73 | props: true, 74 | }, 75 | { 76 | name: 'artist', 77 | path: '/artists/id/:id', 78 | component: ArtistDetails, 79 | props: true, 80 | }, 81 | { 82 | name: 'artist-tracks', 83 | path: '/artists/id/:id/tracks', 84 | component: ArtistTracks, 85 | props: true, 86 | }, 87 | { 88 | name: 'genres', 89 | path: '/genres/:sort?', 90 | component: GenreLibrary, 91 | props: true, 92 | }, 93 | { 94 | name: 'genre', 95 | path: '/genres/id/:id/:section?', 96 | component: GenreDetails, 97 | props: true, 98 | }, 99 | { 100 | name: 'favourites', 101 | path: '/favourites/:section?', 102 | component: Favourites, 103 | props: true, 104 | }, 105 | { 106 | name: 'radio', 107 | path: '/radio', 108 | component: RadioStations, 109 | }, 110 | { 111 | name: 'podcasts', 112 | path: '/podcasts/:sort?', 113 | component: PodcastLibrary, 114 | props: true, 115 | }, 116 | { 117 | name: 'podcast', 118 | path: '/podcasts/id/:id', 119 | component: PodcastDetails, 120 | props: true, 121 | }, 122 | { 123 | name: 'files', 124 | path: '/files/:path*', 125 | component: Files, 126 | props: true, 127 | }, 128 | { 129 | name: 'playlists', 130 | path: '/playlists/:sort?', 131 | component: PlaylistLibrary, 132 | props: true, 133 | }, 134 | { 135 | name: 'playlist', 136 | path: '/playlist/:id', 137 | component: Playlist, 138 | props: true, 139 | }, 140 | { 141 | name: 'search', 142 | path: '/search/:type?', 143 | component: SearchResult, 144 | props: (route) => ({ 145 | ...route.params, 146 | ...route.query, 147 | }) 148 | }, 149 | ], 150 | scrollBehavior(to, from, savedPosition) { 151 | return savedPosition || { x: 0, y: 0 } 152 | }, 153 | }) 154 | 155 | router.beforeEach((to, from, next) => { 156 | if (to.name !== 'login' && !auth.isAuthenticated()) { 157 | next({ name: 'login', query: { returnTo: to.fullPath } }) 158 | } else { 159 | next() 160 | } 161 | }) 162 | 163 | return router 164 | } 165 | -------------------------------------------------------------------------------- /src/player/Queue.vue: -------------------------------------------------------------------------------- 1 | 64 | 131 | 146 | -------------------------------------------------------------------------------- /src/auth/service.ts: -------------------------------------------------------------------------------- 1 | import { md5, randomString, toQueryString } from '@/shared/utils' 2 | import { config } from '@/shared/config' 3 | import { inject } from 'vue' 4 | import { App, Plugin } from '@/shared/compat' 5 | import { pickBy } from 'lodash-es' 6 | 7 | type Auth = { password?: string, salt?: string, hash?: string } 8 | 9 | interface ServerInfo { 10 | name: string 11 | version: string 12 | openSubsonic: boolean 13 | extensions: string[] 14 | } 15 | 16 | export class AuthService { 17 | public server = '' 18 | public serverInfo = null as null | ServerInfo 19 | public username = '' 20 | private salt = '' 21 | private hash = '' 22 | private password = '' 23 | private authenticated = false 24 | 25 | constructor() { 26 | this.server = config.serverUrl || localStorage.getItem('server') || '' 27 | this.username = localStorage.getItem('username') || '' 28 | this.salt = localStorage.getItem('salt') || '' 29 | this.hash = localStorage.getItem('hash') || '' 30 | this.password = localStorage.getItem('password') || '' 31 | } 32 | 33 | private saveSession() { 34 | if (!config.serverUrl) { 35 | localStorage.setItem('server', this.server) 36 | } 37 | localStorage.setItem('username', this.username) 38 | localStorage.setItem('salt', this.salt) 39 | localStorage.setItem('hash', this.hash) 40 | localStorage.setItem('password', this.password) 41 | } 42 | 43 | async autoLogin(): Promise { 44 | if (!this.server || !this.username) { 45 | return false 46 | } 47 | try { 48 | const auth = { salt: this.salt, hash: this.hash, password: this.password } 49 | await login(this.server, this.username, auth) 50 | this.authenticated = true 51 | this.serverInfo = await fetchServerInfo(this.server, this.username, auth) 52 | return true 53 | } catch { 54 | return false 55 | } 56 | } 57 | 58 | async loginWithPassword(server: string, username: string, password: string): Promise { 59 | const salt = randomString() 60 | const hash = md5(password + salt) 61 | try { 62 | await login(server, username, { hash, salt }) 63 | this.salt = salt 64 | this.hash = hash 65 | this.password = '' 66 | } catch { 67 | await login(server, username, { password }) 68 | this.salt = '' 69 | this.hash = '' 70 | this.password = password 71 | } 72 | this.server = server 73 | this.username = username 74 | this.authenticated = true 75 | this.serverInfo = await fetchServerInfo(server, username, { hash, salt, password }) 76 | this.saveSession() 77 | } 78 | 79 | get urlParams() { 80 | return toQueryString(pickBy({ 81 | u: this.username, 82 | s: this.salt, 83 | t: this.hash, 84 | p: this.password, 85 | })) 86 | } 87 | 88 | logout() { 89 | localStorage.clear() 90 | sessionStorage.clear() 91 | } 92 | 93 | isAuthenticated() { 94 | return this.authenticated 95 | } 96 | } 97 | 98 | async function login(server: string, username: string, auth: Auth) { 99 | const qs = toQueryString(pickBy({ 100 | s: auth.salt, 101 | t: auth.hash, 102 | p: auth.password, 103 | }, x => x !== undefined) as Record) 104 | const url = `${server}/rest/ping?u=${username}&${qs}&v=1.15.0&c=app&f=json` 105 | return fetch(url) 106 | .then(response => response.ok 107 | ? response.json() 108 | : Promise.reject(new Error(response.statusText))) 109 | .then((response) => { 110 | const subsonicResponse = response['subsonic-response'] 111 | if (!subsonicResponse || subsonicResponse.status !== 'ok') { 112 | const message = subsonicResponse.error?.message || subsonicResponse.status 113 | throw new Error(message) 114 | } 115 | }) 116 | } 117 | 118 | async function fetchServerInfo(server: string, username: string, auth: Auth): Promise { 119 | const qs = toQueryString(pickBy({ 120 | s: auth.salt, 121 | t: auth.hash, 122 | p: auth.password, 123 | }, x => x !== undefined) as Record) 124 | const url = `${server}/rest/getOpenSubsonicExtensions?u=${username}&${qs}&v=1.15.0&c=app&f=json` 125 | const response = await fetch(url) 126 | if (response.ok) { 127 | const body = await response.json() 128 | const subsonicResponse = body['subsonic-response'] 129 | if (subsonicResponse?.status === 'ok') { 130 | return { 131 | name: subsonicResponse.type, 132 | version: subsonicResponse.version, 133 | openSubsonic: true, 134 | extensions: subsonicResponse.openSubsonicExtensions.map((ext: any) => ext.name) 135 | } 136 | } 137 | } 138 | return { name: 'Subsonic', version: 'Unknown', openSubsonic: false, extensions: [] } 139 | } 140 | 141 | const apiSymbol = Symbol('') 142 | 143 | export function useAuth(): AuthService { 144 | return inject(apiSymbol) as AuthService 145 | } 146 | 147 | export function createAuth(): AuthService & Plugin { 148 | const instance = new AuthService() 149 | return Object.assign(instance, { 150 | install: (app: App) => { 151 | app.provide(apiSymbol, instance) 152 | } 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /src/library/playlist/Playlist.vue: -------------------------------------------------------------------------------- 1 |