diff --git a/lefthook.yml b/lefthook.yml deleted file mode 100644 index 55b3a712..00000000 --- a/lefthook.yml +++ /dev/null @@ -1,5 +0,0 @@ -# https://lefthook.dev/configuration/ -pre-commit: - parallel: true - jobs: - - run: npm run gen:translations && git add src/translations diff --git a/package-lock.json b/package-lock.json index 7ee7e29c..e6fe8787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@tauri-apps/plugin-process": "2.3.0", "browserslist": "4.26.3", "classnames": "2.5.1", + "eventemitter3": "5.0.1", "lodash-es": "4.17.21", "nanoid": "5.1.6", "normalize.css": "8.0.1", @@ -53,9 +54,9 @@ "@types/semver": "7.7.1", "@vitejs/plugin-react": "5.0.4", "@vitest/browser": "3.2.4", - "lefthook": "1.13.6", "lightningcss": "1.30.2", "playwright": "1.56.0", + "simple-git-hooks": "2.13.1", "typescript": "5.9.3", "typescript-plugin-css-modules": "5.2.0", "vite": "7.1.9", @@ -9041,168 +9042,11 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "node_modules/lefthook": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-1.13.6.tgz", - "integrity": "sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "lefthook": "bin/index.js" - }, - "optionalDependencies": { - "lefthook-darwin-arm64": "1.13.6", - "lefthook-darwin-x64": "1.13.6", - "lefthook-freebsd-arm64": "1.13.6", - "lefthook-freebsd-x64": "1.13.6", - "lefthook-linux-arm64": "1.13.6", - "lefthook-linux-x64": "1.13.6", - "lefthook-openbsd-arm64": "1.13.6", - "lefthook-openbsd-x64": "1.13.6", - "lefthook-windows-arm64": "1.13.6", - "lefthook-windows-x64": "1.13.6" - } - }, - "node_modules/lefthook/node_modules/lefthook-darwin-arm64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-1.13.6.tgz", - "integrity": "sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/lefthook/node_modules/lefthook-darwin-x64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-1.13.6.tgz", - "integrity": "sha512-CoRpdzanu9RK3oXR1vbEJA5LN7iB+c7hP+sONeQJzoOXuq4PNKVtEaN84Gl1BrVtCNLHWFAvCQaZPPiiXSy8qg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/lefthook/node_modules/lefthook-freebsd-arm64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-1.13.6.tgz", - "integrity": "sha512-X4A7yfvAJ68CoHTqP+XvQzdKbyd935sYy0bQT6Ajz7FL1g7hFiro8dqHSdPdkwei9hs8hXeV7feyTXbYmfjKQQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/lefthook/node_modules/lefthook-freebsd-x64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-1.13.6.tgz", - "integrity": "sha512-ai2m+Sj2kGdY46USfBrCqLKe9GYhzeq01nuyDYCrdGISePeZ6udOlD1k3lQKJGQCHb0bRz4St0r5nKDSh1x/2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/lefthook/node_modules/lefthook-linux-arm64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-1.13.6.tgz", - "integrity": "sha512-cbo4Wtdq81GTABvikLORJsAWPKAJXE8Q5RXsICFUVznh5PHigS9dFW/4NXywo0+jfFPCT6SYds2zz4tCx6DA0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/lefthook/node_modules/lefthook-linux-x64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-1.13.6.tgz", - "integrity": "sha512-uJl9vjCIIBTBvMZkemxCE+3zrZHlRO7Oc+nZJ+o9Oea3fu+W82jwX7a7clw8jqNfaeBS+8+ZEQgiMHWCloTsGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/lefthook/node_modules/lefthook-openbsd-arm64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-1.13.6.tgz", - "integrity": "sha512-7r153dxrNRQ9ytRs2PmGKKkYdvZYFPre7My7XToSTiRu5jNCq++++eAKVkoyWPduk97dGIA+YWiEr5Noe0TK2A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/lefthook/node_modules/lefthook-openbsd-x64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-1.13.6.tgz", - "integrity": "sha512-Z+UhLlcg1xrXOidK3aLLpgH7KrwNyWYE3yb7ITYnzJSEV8qXnePtVu8lvMBHs/myzemjBzeIr/U/+ipjclR06g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/lefthook/node_modules/lefthook-windows-arm64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-1.13.6.tgz", - "integrity": "sha512-Uxef6qoDxCmUNQwk8eBvddYJKSBFglfwAY9Y9+NnnmiHpWTjjYiObE9gT2mvGVpEgZRJVAatBXc+Ha5oDD/OgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/lefthook/node_modules/lefthook-windows-x64": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-1.13.6.tgz", - "integrity": "sha512-mOZoM3FQh3o08M8PQ/b3IYuL5oo36D9ehczIw1dAgp1Ly+Tr4fJ96A+4SEJrQuYeRD4mex9bR7Ps56I73sBSZA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" }, "node_modules/lightningcss": { "version": "1.30.2", @@ -12150,6 +11994,17 @@ "node": ">=10" } }, + "node_modules/simple-git-hooks": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/simple-git-hooks/-/simple-git-hooks-2.13.1.tgz", + "integrity": "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "simple-git-hooks": "cli.js" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 8e52b0a4..c9d8b5f4 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test:lint": "biome ci .", "test:lint:fix": "biome check --write .", "package:checksums": "bash scripts/checksum.sh", - "clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml" + "clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml", + "postinstall": "simple-git-hooks" }, "dependencies": { "@dnd-kit/core": "6.3.1", @@ -50,6 +51,7 @@ "@tauri-apps/plugin-process": "2.3.0", "browserslist": "4.26.3", "classnames": "2.5.1", + "eventemitter3": "5.0.1", "lodash-es": "4.17.21", "nanoid": "5.1.6", "normalize.css": "8.0.1", @@ -74,9 +76,9 @@ "@types/semver": "7.7.1", "@vitejs/plugin-react": "5.0.4", "@vitest/browser": "3.2.4", - "lefthook": "1.13.6", "lightningcss": "1.30.2", "playwright": "1.56.0", + "simple-git-hooks": "2.13.1", "typescript": "5.9.3", "typescript-plugin-css-modules": "5.2.0", "vite": "7.1.9", @@ -89,5 +91,8 @@ "@biomejs/biome", "@parcel/watcher", "esbuild" - ] + ], + "simple-git-hooks": { + "pre-commit": "npm run gen:translations && git add src/translations" + } } diff --git a/src/components/ButtonRepeat.tsx b/src/components/ButtonRepeat.tsx index f27c9400..14389b36 100644 --- a/src/components/ButtonRepeat.tsx +++ b/src/components/ButtonRepeat.tsx @@ -1,11 +1,11 @@ import { useLingui } from '@lingui/react/macro'; import ButtonIcon from '../elements/ButtonIcon'; -import usePlayerStore, { usePlayerAPI } from '../stores/usePlayerStore'; +import { usePlayerState } from '../hooks/usePlayer'; +import player from '../lib/player'; export default function ButtonRepeat() { - const repeat = usePlayerStore((state) => state.repeat); - const playerAPI = usePlayerAPI(); + const repeat = usePlayerState((state) => state.repeat); const { t } = useLingui(); let pressed: boolean | 'mixed' = false; @@ -18,7 +18,7 @@ export default function ButtonRepeat() { title={t`Repeat`} icon={repeat === 'One' ? 'repeatOne' : 'repeat'} iconSize={20} - onClick={() => playerAPI.toggleRepeat()} + onClick={player.toggleRepeat} isActive={repeat === 'One' || repeat === 'All'} aria-pressed={pressed} /> diff --git a/src/components/ButtonShuffle.tsx b/src/components/ButtonShuffle.tsx index b7bafbbf..33580a90 100644 --- a/src/components/ButtonShuffle.tsx +++ b/src/components/ButtonShuffle.tsx @@ -1,16 +1,16 @@ import { t } from '@lingui/core/macro'; import ButtonIcon from '../elements/ButtonIcon'; -import usePlayerStore, { usePlayerAPI } from '../stores/usePlayerStore'; +import { usePlayerState } from '../hooks/usePlayer'; +import player from '../lib/player'; export default function ButtonShuffle() { - const shuffle = usePlayerStore((state) => state.shuffle); - const playerAPI = usePlayerAPI(); + const shuffle = usePlayerState((state) => state.shuffle); return ( playerAPI.toggleShuffle()} + onClick={player.toggleShuffle} icon={'shuffle'} iconSize={20} isActive={shuffle} diff --git a/src/components/GlobalKeyBindings.tsx b/src/components/GlobalKeyBindings.tsx index b029c9fa..8e71abf7 100644 --- a/src/components/GlobalKeyBindings.tsx +++ b/src/components/GlobalKeyBindings.tsx @@ -3,43 +3,37 @@ import Keybinding from 'react-keybinding-component'; import SettingsBridge from '../lib/bridge-settings'; import player from '../lib/player'; -import { usePlayerAPI } from '../stores/usePlayerStore'; /** * Handle app-level IPC Navigation events */ function GlobalKeyBindings() { - const playerAPI = usePlayerAPI(); - // App shortcuts (not using global shortcuts API to avoid conflicts // with other applications) - const onKey = useCallback( - async (e: KeyboardEvent) => { - switch (e.key) { - case ' ': - e.preventDefault(); - e.stopPropagation(); - playerAPI.playPause(); - break; - case 'ArrowLeft': - e.preventDefault(); - e.stopPropagation(); - playerAPI.jumpTo(player.getCurrentTime() - 10); - break; - case 'ArrowRight': - e.preventDefault(); - e.stopPropagation(); - playerAPI.jumpTo(player.getCurrentTime() + 10); - break; - case 'Alt': - await SettingsBridge.toggleMenu(); - break; - default: - break; - } - }, - [playerAPI], - ); + const onKey = useCallback(async (e: KeyboardEvent) => { + switch (e.key) { + case ' ': + e.preventDefault(); + e.stopPropagation(); + player.playPause(); + break; + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); + player.setCurrentTime(player.getCurrentTime() - 10); + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); + player.setCurrentTime(player.getCurrentTime() + 10); + break; + case 'Alt': + await SettingsBridge.toggleMenu(); + break; + default: + break; + } + }, []); return ; } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 89ea6267..2149140a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,8 +2,8 @@ import { useLingui } from '@lingui/react/macro'; import { Popover } from 'radix-ui'; import ButtonIcon from '../elements/ButtonIcon'; +import { usePlayerState } from '../hooks/usePlayer'; import usePlayingTrack from '../hooks/usePlayingTrack'; -import usePlayerStore from '../stores/usePlayerStore'; import styles from './Header.module.css'; import PlayerControls from './PlayerControls'; import PlayingBar from './PlayingBar'; @@ -11,8 +11,8 @@ import Queue from './Queue'; import Search from './Search'; export default function Header() { - const queue = usePlayerStore((state) => state.queue); - const queueCursor = usePlayerStore((state) => state.queueCursor); + const queue = usePlayerState((state) => state.queue); + const queueCursor = usePlayerState((state) => state.queueCursor); const trackPlaying = usePlayingTrack(); const { t } = useLingui(); diff --git a/src/components/IPCNavigationEvents.tsx b/src/components/IPCNavigationEvents.tsx index 3d700b44..6e0606f7 100644 --- a/src/components/IPCNavigationEvents.tsx +++ b/src/components/IPCNavigationEvents.tsx @@ -3,9 +3,9 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { useCallback, useEffect } from 'react'; import Keybinding from 'react-keybinding-component'; +import { usePlayerState } from '../hooks/usePlayer'; import { goToPlayingTrack } from '../lib/queue-origin'; import { isCtrlKey } from '../lib/utils-events'; -import usePlayerStore from '../stores/usePlayerStore'; /** * Event Handlers for Navigation Events, triggered via IPC, typically, from @@ -13,7 +13,7 @@ import usePlayerStore from '../stores/usePlayerStore'; */ export default function IPCNavigationEvents() { const navigate = useNavigate(); - const queueOrigin = usePlayerStore((state) => state.queueOrigin); + const queueOrigin = usePlayerState((state) => state.queueOrigin); // Navigation handlers const goToLibrary = useCallback(() => { diff --git a/src/components/IPCPlayerEvents.tsx b/src/components/IPCPlayerEvents.tsx index 0e0f4a01..d73fcbb2 100644 --- a/src/components/IPCPlayerEvents.tsx +++ b/src/components/IPCPlayerEvents.tsx @@ -2,27 +2,25 @@ import { listen } from '@tauri-apps/api/event'; import { useEffect } from 'react'; import type { IPCEvent, Track } from '../generated/typings'; -import { usePlayerAPI } from '../stores/usePlayerStore'; +import player from '../lib/player'; /** * Handle back-end events attempting to control the player */ function IPCPlayerEvents() { - const playerAPI = usePlayerAPI(); - useEffect(() => { const unlistenerPromises = [ - listen('PlaybackPlay' satisfies IPCEvent, playerAPI.play), - listen('PlaybackPause' satisfies IPCEvent, playerAPI.pause), - listen('PlaybackPlayPause' satisfies IPCEvent, playerAPI.playPause), - listen('PlaybackPrevious' satisfies IPCEvent, playerAPI.previous), - listen('PlaybackNext' satisfies IPCEvent, playerAPI.next), - listen('PlaybackStop' satisfies IPCEvent, playerAPI.stop), + listen('PlaybackPlay' satisfies IPCEvent, player.play), + listen('PlaybackPause' satisfies IPCEvent, player.pause), + listen('PlaybackPlayPause' satisfies IPCEvent, player.playPause), + listen('PlaybackPrevious' satisfies IPCEvent, player.previous), + listen('PlaybackNext' satisfies IPCEvent, player.next), + listen('PlaybackStop' satisfies IPCEvent, player.stop), listen( 'PlaybackStart' satisfies IPCEvent, ({ payload }: { payload: Track[] }) => { if (payload.length > 0) { - playerAPI.start(payload, payload[0].id, { type: 'library' }); + player.start(payload, payload[0].id, { type: 'library' }); } }, ), @@ -35,7 +33,7 @@ function IPCPlayerEvents() { }); }); }; - }, [playerAPI]); + }, []); return null; } diff --git a/src/components/MediaSessionEvents.tsx b/src/components/MediaSessionEvents.tsx deleted file mode 100644 index 231c1a74..00000000 --- a/src/components/MediaSessionEvents.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useEffect } from 'react'; - -import { getCover } from '../lib/cover'; -import player from '../lib/player'; -import { usePlayerAPI } from '../stores/usePlayerStore'; - -/** - * Integration for MediaSession (mpris, macOS player controls etc)... - */ -function MediaSessionEvents() { - const playerAPI = usePlayerAPI(); - - useEffect(() => { - if ( - 'mediaSession' in navigator && - typeof navigator.mediaSession === 'object' - ) { - player.getAudio().addEventListener('loadstart', syncMediaSession); - player.getAudio().addEventListener('play', onAudioPlay); - player.getAudio().addEventListener('pause', onAudioPause); - - navigator.mediaSession.setActionHandler('play', () => playerAPI.play()); - navigator.mediaSession.setActionHandler('pause', () => playerAPI.pause()); - navigator.mediaSession.setActionHandler('previoustrack', () => - playerAPI.previous(), - ); - navigator.mediaSession.setActionHandler('nexttrack', () => - playerAPI.next(), - ); - } - - return function cleanup() { - if ( - 'mediaSession' in navigator && - typeof navigator.mediaSession === 'object' - ) { - player.getAudio().removeEventListener('loadstart', syncMediaSession); - player.getAudio().removeEventListener('play', onAudioPlay); - player.getAudio().removeEventListener('pause', onAudioPause); - - navigator.mediaSession.setActionHandler('play', null); - navigator.mediaSession.setActionHandler('pause', null); - navigator.mediaSession.setActionHandler('previoustrack', null); - navigator.mediaSession.setActionHandler('nexttrack', null); - } - }; - }, [playerAPI]); - - return null; -} - -export default MediaSessionEvents; - -/** - * Helpers - */ - -async function syncMediaSession() { - const track = player.getTrack(); - - if (track && 'MediaMetadata' in globalThis) { - const cover = await getCover(track.path); - - navigator.mediaSession.metadata = new MediaMetadata({ - title: track.title, - artist: track.artists.join(', '), - album: track.album, - artwork: cover ? [{ src: cover }] : undefined, - }); - } -} - -function onAudioPlay() { - navigator.mediaSession.playbackState = 'playing'; -} - -function onAudioPause() { - navigator.mediaSession.playbackState = 'paused'; -} diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index 968240a2..bca34300 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -1,14 +1,13 @@ import { useLingui } from '@lingui/react/macro'; import ButtonIcon from '../elements/ButtonIcon'; -import usePlayerStore, { usePlayerAPI } from '../stores/usePlayerStore'; -import { PlayerStatus } from '../types/museeks'; +import { usePlayerState } from '../hooks/usePlayer'; +import player from '../lib/player'; import styles from './PlayerControls.module.css'; import VolumeControl from './VolumeControl'; export default function PlayerControls() { - const playerAPI = usePlayerAPI(); - const playerStatus = usePlayerStore((state) => state.playerStatus); + const isPaused = usePlayerState((state) => state.isPaused); const { t } = useLingui(); return ( @@ -17,25 +16,21 @@ export default function PlayerControls() { icon="skipBack" iconSize={16} title={t`Previous`} - onClick={playerAPI.previous} + onClick={player.previous} data-testid="playercontrol-skipback" /> diff --git a/src/components/PlayerEvents.tsx b/src/components/PlayerEvents.tsx index c167cef7..85a61a5b 100644 --- a/src/components/PlayerEvents.tsx +++ b/src/components/PlayerEvents.tsx @@ -3,12 +3,12 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { sendNotification } from '@tauri-apps/plugin-notification'; import { useEffect } from 'react'; +import { usePlayerState } from '../hooks/usePlayer'; import ConfigBridge from '../lib/bridge-config'; import { getCover } from '../lib/cover'; import player from '../lib/player'; import { goToPlayingTrack } from '../lib/queue-origin'; import { logAndNotifyError } from '../lib/utils'; -import usePlayerStore, { usePlayerAPI } from '../stores/usePlayerStore'; import { useToastsAPI } from '../stores/useToastsStore'; const AUDIO_ERRORS = { @@ -20,40 +20,30 @@ const AUDIO_ERRORS = { }; /** - * Handle some of the logic regarding the player. Technically, it should not be there, - * but part of the Player library, but cleaning up events in case of hot-reload is tough + * Handle player events for notifications and error handling */ function PlayerEvents() { - const playerAPI = usePlayerAPI(); const toastsAPI = useToastsAPI(); - const queueOrigin = usePlayerStore((state) => state.queueOrigin); + const queueOrigin = usePlayerState((state) => state.queueOrigin); const navigate = useNavigate(); useEffect(() => { - function handleAudioError(e: ErrorEvent) { - playerAPI.stop(); - - const element = e.target as HTMLAudioElement; - - if (element) { - const { error } = element; - - if (!error) return; - - switch (error.code) { - case error.MEDIA_ERR_ABORTED: - toastsAPI.add('warning', AUDIO_ERRORS.aborted); - break; - case error.MEDIA_ERR_DECODE: - toastsAPI.add('danger', AUDIO_ERRORS.corrupt); - break; - case error.MEDIA_ERR_SRC_NOT_SUPPORTED: - toastsAPI.add('danger', AUDIO_ERRORS.notFound); - break; - default: - toastsAPI.add('danger', AUDIO_ERRORS.unknown); - break; - } + function handleAudioError(error: MediaError) { + player.stop(); + + switch (error.code) { + case error.MEDIA_ERR_ABORTED: + toastsAPI.add('warning', AUDIO_ERRORS.aborted); + break; + case error.MEDIA_ERR_DECODE: + toastsAPI.add('danger', AUDIO_ERRORS.corrupt); + break; + case error.MEDIA_ERR_SRC_NOT_SUPPORTED: + toastsAPI.add('danger', AUDIO_ERRORS.notFound); + break; + default: + toastsAPI.add('danger', AUDIO_ERRORS.unknown); + break; } } @@ -84,12 +74,10 @@ function PlayerEvents() { } async function onTrackEnded() { - await playerAPI.next(); - const isFocused = await getCurrentWindow().isFocused(); // If follow track is enabled, switch to the right view + scroll to the track - // Do not do it if the app if focused, as users could be interacting with the app + // Do not do it if the app is focused, as users could be interacting with the app if ( (await ConfigBridge.get('audio_follow_playing_track')) && !isFocused @@ -99,17 +87,16 @@ function PlayerEvents() { } // Bind player events - // Audio Events - player.getAudio().addEventListener('play', notifyTrackChange); - player.getAudio().addEventListener('error', handleAudioError); - player.getAudio().addEventListener('ended', onTrackEnded); + player.on('play', notifyTrackChange); + player.on('error', handleAudioError); + player.on('ended', onTrackEnded); return function cleanup() { - player.getAudio().removeEventListener('play', notifyTrackChange); - player.getAudio().removeEventListener('error', handleAudioError); - player.getAudio().removeEventListener('ended', onTrackEnded); + player.off('play', notifyTrackChange); + player.off('error', handleAudioError); + player.off('ended', onTrackEnded); }; - }, [toastsAPI, playerAPI, queueOrigin, navigate]); + }, [toastsAPI, queueOrigin, navigate]); return null; } diff --git a/src/components/PlayingBar.tsx b/src/components/PlayingBar.tsx index 2bb41f17..df2467b8 100644 --- a/src/components/PlayingBar.tsx +++ b/src/components/PlayingBar.tsx @@ -1,5 +1,5 @@ import type { Track } from '../generated/typings'; -import usePlayerStore from '../stores/usePlayerStore'; +import { usePlayerState } from '../hooks/usePlayer'; import ButtonRepeat from './ButtonRepeat'; import ButtonShuffle from './ButtonShuffle'; import Cover from './Cover'; @@ -11,8 +11,8 @@ type Props = { }; export default function PlayingBar(props: Props) { - const repeat = usePlayerStore((state) => state.repeat); - const shuffle = usePlayerStore((state) => state.shuffle); + const repeat = usePlayerState((state) => state.repeat); + const shuffle = usePlayerState((state) => state.shuffle); const trackPlaying = props.trackPlaying; return ( diff --git a/src/components/PlayingIndicator.tsx b/src/components/PlayingIndicator.tsx index bab69002..a4932ac7 100644 --- a/src/components/PlayingIndicator.tsx +++ b/src/components/PlayingIndicator.tsx @@ -1,17 +1,16 @@ import { useMemo, useState } from 'react'; -import usePlayerStore, { usePlayerAPI } from '../stores/usePlayerStore'; -import { PlayerStatus } from '../types/museeks'; +import { usePlayerState } from '../hooks/usePlayer'; +import player from '../lib/player'; import Icon from './Icon'; import styles from './PlayingIndicator.module.css'; export default function TrackPlayingIndicator() { const [hovered, setHovered] = useState(false); - const playerStatus = usePlayerStore((state) => state.playerStatus); - const playerAPI = usePlayerAPI(); + const isPaused = usePlayerState((state) => state.isPaused); const icon = useMemo(() => { - if (playerStatus === PlayerStatus.PLAY) { + if (!isPaused) { if (hovered) { return ; } @@ -26,13 +25,13 @@ export default function TrackPlayingIndicator() { } return ; - }, [playerStatus, hovered]); + }, [isPaused, hovered]); return ( diff --git a/src/components/QueueListItem.tsx b/src/components/QueueListItem.tsx index 49ea1c1b..d5524dcf 100644 --- a/src/components/QueueListItem.tsx +++ b/src/components/QueueListItem.tsx @@ -3,7 +3,7 @@ import { CSS } from '@dnd-kit/utilities'; import { useCallback } from 'react'; import type { Track } from '../generated/typings'; -import { usePlayerAPI } from '../stores/usePlayerStore'; +import player from '../lib/player'; import Cover from './Cover'; import styles from './QueueListItem.module.css'; @@ -14,8 +14,6 @@ type Props = { }; export default function QueueListItem(props: Props) { - const playerAPI = usePlayerAPI(); - const { track } = props; const { attributes, listeners, setNodeRef, transform, transition } = @@ -32,12 +30,12 @@ export default function QueueListItem(props: Props) { }; const remove = useCallback(() => { - playerAPI.removeFromQueue(props.index); - }, [props.index, playerAPI]); + player.removeFromQueue(props.index); + }, [props.index]); const play = useCallback(() => { - playerAPI.startFromQueue(props.queueCursor + props.index + 1); - }, [props.index, props.queueCursor, playerAPI]); + player.startFromQueue(props.queueCursor + props.index + 1); + }, [props.index, props.queueCursor]); return (
{ - playerAPI.start(tracks, trackID, queueOrigin); + player.start(tracks, trackID, queueOrigin); }, - [tracks, playerAPI, queueOrigin], + [tracks, queueOrigin], ); /** @@ -300,14 +300,18 @@ export default function TrackList(props: Props) { // Queue Management MenuItem.new({ text: t`Add to queue`, - action() { - playerAPI.addInQueue(Array.from(selectedTracks)); + async action() { + player.addToQueue( + await BridgeDatabase.getTracks(Array.from(selectedTracks)), + ); }, }), MenuItem.new({ text: t`Play next`, - action() { - playerAPI.addNextInQueue(Array.from(selectedTracks)); + async action() { + player.addNextInQueue( + await BridgeDatabase.getTracks(Array.from(selectedTracks)), + ); }, }), PredefinedMenuItem.new({ @@ -411,7 +415,6 @@ export default function TrackList(props: Props) { selectedTracks, tracks, navigate, - playerAPI, libraryAPI, toastsAPI, invalidate, diff --git a/src/components/TrackProgress.tsx b/src/components/TrackProgress.tsx index 0bff0cc4..8626c539 100644 --- a/src/components/TrackProgress.tsx +++ b/src/components/TrackProgress.tsx @@ -4,7 +4,7 @@ import { useCallback, useState } from 'react'; import type { Track } from '../generated/typings'; import useFormattedDuration from '../hooks/useFormattedDuration'; import usePlayingTrackCurrentTime from '../hooks/usePlayingTrackCurrentTime'; -import { usePlayerAPI } from '../stores/usePlayerStore'; +import player from '../lib/player'; import styles from './TrackProgress.module.css'; type Props = { @@ -15,16 +15,11 @@ export default function TrackProgress(props: Props) { const { trackPlaying } = props; const elapsed = usePlayingTrackCurrentTime(); - const playerAPI = usePlayerAPI(); - const jumpAudioTo = useCallback( - (values: number[]) => { - const [to] = values; - - playerAPI.jumpTo(to); - }, - [playerAPI], - ); + const jumpAudioTo = useCallback((values: number[]) => { + const [to] = values; + player.setCurrentTime(to); + }, []); const [tooltipTargetTime, setTooltipTargetTime] = useState( null, diff --git a/src/components/VolumeControl.tsx b/src/components/VolumeControl.tsx index 76293534..7f7d4835 100644 --- a/src/components/VolumeControl.tsx +++ b/src/components/VolumeControl.tsx @@ -4,9 +4,9 @@ import { Slider } from 'radix-ui'; import { useCallback, useState } from 'react'; import ButtonIcon from '../elements/ButtonIcon'; +import { usePlayerState } from '../hooks/usePlayer'; import player from '../lib/player'; import { stopPropagation } from '../lib/utils-events'; -import { usePlayerAPI } from '../stores/usePlayerStore'; import type { IconName } from './Icon'; import styles from './VolumeControl.module.css'; @@ -26,33 +26,16 @@ const getVolumeIcon = (volume: number, muted: boolean): IconName => { }; export default function VolumeControl() { - const audio = player.getAudio(); - + const volume = usePlayerState((state) => state.volume); + const muted = usePlayerState((state) => state.muted); const [showVolume, setShowVolume] = useState(false); - const [volume, setVolume] = useState(audio.volume); - const [muted, setMuted] = useState(audio.muted); const { t } = useLingui(); - const playerAPI = usePlayerAPI(); - - const setPlayerVolume = useCallback( - (values: number[]) => { - const [value] = values; - const smoothVolume = smoothifyVolume(value); - - playerAPI.setVolume(smoothVolume); - setVolume(smoothVolume); - }, - [playerAPI], - ); - - // TODO: move to player actions - const mute = useCallback(() => { - const isMuted = !player.isMuted(); - - playerAPI.setMuted(isMuted); - setMuted(isMuted); - }, [playerAPI]); + const setPlayerVolume = useCallback((values: number[]) => { + const [value] = values; + const smoothVolume = smoothifyVolume(value); + player.setVolume(smoothVolume); // Debounced save happens in player + }, []); const volumeClasses = cx(styles.volumeControl, { [styles.visible]: showVolume, @@ -71,7 +54,7 @@ export default function VolumeControl() { > diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts new file mode 100644 index 00000000..2a8cb1a5 --- /dev/null +++ b/src/hooks/usePlayer.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from 'react'; + +import type { Track } from '../generated/typings'; +import player, { type PlayerState as PlayerStateType } from '../lib/player'; + +/** + * Hook to get a specific slice of player state. + * Uses useSyncExternalStore for optimal performance and only re-renders when the selected value changes. + */ +export function usePlayerState(selector: (state: PlayerStateType) => T): T { + return useSyncExternalStore( + (callback) => { + player.on('stateChange', callback); + return () => { + player.off('stateChange', callback); + }; + }, + () => selector(player.getState()), + () => selector(player.getState()), + ); +} + +/** + * Hook to get the currently playing track + */ +export function usePlayingTrack(): Track | null { + return usePlayerState((state) => { + if (state.queue.length > 0 && state.queueCursor !== null) { + return state.queue[state.queueCursor]; + } + return null; + }); +} + +/** + * Hook to get current playback time that updates frequently. + * Uses useSyncExternalStore for optimal performance. + */ +export function usePlayingTrackCurrentTime(): number { + return useSyncExternalStore( + (callback) => { + player.on('timeupdate', callback); + return () => { + player.off('timeupdate', callback); + }; + }, + () => player.getCurrentTime(), + () => player.getCurrentTime(), + ); +} diff --git a/src/hooks/usePlayingTrack.ts b/src/hooks/usePlayingTrack.ts index d39cfa37..41190557 100644 --- a/src/hooks/usePlayingTrack.ts +++ b/src/hooks/usePlayingTrack.ts @@ -1,8 +1,8 @@ import type { Track } from '../generated/typings'; -import usePlayerStore from '../stores/usePlayerStore'; +import { usePlayerState } from './usePlayer'; export default function usePlayingTrack(): Track | null { - return usePlayerStore((state) => { + return usePlayerState((state) => { if (state.queue.length > 0 && state.queueCursor !== null) { return state.queue[state.queueCursor]; } diff --git a/src/hooks/usePlayingTrackCurrentTime.ts b/src/hooks/usePlayingTrackCurrentTime.ts index a43eed65..1c43f74e 100644 --- a/src/hooks/usePlayingTrackCurrentTime.ts +++ b/src/hooks/usePlayingTrackCurrentTime.ts @@ -9,14 +9,14 @@ export default function usePlayingTrackCurrentTime(): number { const [currentTime, setCurrentTime] = useState(player.getCurrentTime()); useEffect(() => { - function tick() { - setCurrentTime(player.getCurrentTime()); + function tick(time: number) { + setCurrentTime(time); } - player.getAudio().addEventListener('timeupdate', tick); + player.on('timeupdate', tick); return () => { - player.getAudio().removeEventListener('timeupdate', tick); + player.off('timeupdate', tick); }; }, []); diff --git a/src/lib/player.ts b/src/lib/player.ts index 737adbf5..22331e5e 100644 --- a/src/lib/player.ts +++ b/src/lib/player.ts @@ -1,49 +1,272 @@ import { convertFileSrc } from '@tauri-apps/api/core'; +import EventEmitter from 'eventemitter3'; +import debounce from 'lodash-es/debounce'; -import type { Track } from '../generated/typings'; +import type { Repeat, Track } from '../generated/typings'; +import type { QueueOrigin } from '../types/museeks'; import ConfigBridge from './bridge-config'; +import { getCover } from './cover'; +import { logAndNotifyError } from './utils'; +import { shuffleTracks } from './utils-player'; interface PlayerOptions { playbackRate?: number; - audioOutputDevice?: string; volume?: number; muted?: boolean; + repeat?: Repeat; + shuffle?: boolean; } /** - * Library in charge of playing audio. Currently uses HTMLAudioElement. - * - * Open questions: - * - Should it emit IPC events itself? Or expose events? - * - Should it hold the concepts of queue/random/etc? (in other words, should - * we merge player actions here?) + * Player state that gets emitted on changes */ -class Player { +export interface PlayerState { + queue: Track[]; + queueCursor: number | null; + queueOrigin: QueueOrigin | null; + repeat: Repeat; + shuffle: boolean; + volume: number; + muted: boolean; + isPaused: boolean; +} + +/** + * Events emitted by the Player + */ +export interface PlayerEvents { + // Playback events + play: () => void; + pause: () => void; + stop: () => void; + ended: () => void; + error: (error: MediaError) => void; + timeupdate: (currentTime: number) => void; + loadstart: () => void; + + // State change event (for React hooks) + stateChange: (state: PlayerState) => void; + + // Track change event + trackChange: (track: Track | null) => void; +} + +/** + * Enhanced Player class that manages both audio playback and queue state. + * Extends EventEmitter to notify React components of state changes. + */ +class Player extends EventEmitter { private readonly audio: HTMLAudioElement; - private track: Track | null; private blobUrl: string | null; + // Queue state + private queue: Track[]; + private oldQueue: Track[]; // Backup for shuffle/unshuffle + private queueCursor: number | null; + private queueOrigin: QueueOrigin | null; + + // Playback modes + private repeat: Repeat; + private shuffle: boolean; + + // Cached state for useSyncExternalStore + private state: PlayerState | null = null; + constructor(options?: PlayerOptions) { + super(); + const mergedOptions = { playbackRate: 1, volume: 1, muted: false, + repeat: 'None' as Repeat, + shuffle: false, ...options, }; this.audio = new Audio(); - this.track = null; this.blobUrl = null; + this.queue = []; + this.oldQueue = []; + this.queueCursor = null; + this.queueOrigin = null; + this.repeat = mergedOptions.repeat; + this.shuffle = mergedOptions.shuffle; this.audio.defaultPlaybackRate = mergedOptions.playbackRate; this.audio.playbackRate = mergedOptions.playbackRate; this.audio.volume = mergedOptions.volume; this.audio.muted = mergedOptions.muted; + + this.setupAudioListeners(); + this.setupMediaSession(); + + // Bind methods that are used as callbacks + this.play = this.play.bind(this); + this.pause = this.pause.bind(this); + this.playPause = this.playPause.bind(this); + this.previous = this.previous.bind(this); + this.next = this.next.bind(this); + this.stop = this.stop.bind(this); + this.start = this.start.bind(this); + this.startFromQueue = this.startFromQueue.bind(this); + this.addToQueue = this.addToQueue.bind(this); + this.addNextInQueue = this.addNextInQueue.bind(this); + this.removeFromQueue = this.removeFromQueue.bind(this); + this.clearQueue = this.clearQueue.bind(this); + this.setQueue = this.setQueue.bind(this); + this.toggleShuffle = this.toggleShuffle.bind(this); + this.toggleRepeat = this.toggleRepeat.bind(this); + this.setTrack = this.setTrack.bind(this); + this.setCurrentTime = this.setCurrentTime.bind(this); + this.setVolume = this.setVolume.bind(this); + this.toggleMute = this.toggleMute.bind(this); + this.setPlaybackRate = this.setPlaybackRate.bind(this); + } + + /** + * Setup internal audio element event listeners + */ + private setupAudioListeners() { + this.audio.addEventListener('play', () => { + this.emit('play'); + this.emitStateChange(); + }); + + this.audio.addEventListener('pause', () => { + this.emit('pause'); + this.emitStateChange(); + }); + + this.audio.addEventListener('ended', async () => { + this.emit('ended'); + // Auto-advance to next track + await this.next(); + }); + + this.audio.addEventListener('error', () => { + if (this.audio.error) { + this.emit('error', this.audio.error); + } + }); + + this.audio.addEventListener('timeupdate', () => { + this.emit('timeupdate', this.audio.currentTime); + }); + + this.audio.addEventListener('loadstart', () => { + this.emit('loadstart'); + }); + + // Emit volume changes + this.audio.addEventListener('volumechange', () => { + this.emitStateChange(); + }); + } + + /** + * Setup MediaSession integration (for OS media controls, MPRIS, etc.) + */ + private setupMediaSession() { + if (!('mediaSession' in navigator)) { + return; + } + + // Update playback state when audio plays + this.audio.addEventListener('play', () => { + navigator.mediaSession.playbackState = 'playing'; + }); + + // Update playback state when audio pauses + this.audio.addEventListener('pause', () => { + navigator.mediaSession.playbackState = 'paused'; + }); + + // Sync metadata when track loads + this.audio.addEventListener('loadstart', async () => { + await this.syncMediaSession(); + }); + + // Setup action handlers + navigator.mediaSession.setActionHandler('play', () => { + this.play().catch(logAndNotifyError); + }); + navigator.mediaSession.setActionHandler('pause', () => { + this.pause(); + }); + navigator.mediaSession.setActionHandler('previoustrack', () => { + this.previous().catch(logAndNotifyError); + }); + navigator.mediaSession.setActionHandler('nexttrack', () => { + this.next().catch(logAndNotifyError); + }); + } + + /** + * Sync current track metadata with MediaSession API + */ + private async syncMediaSession() { + if (!('mediaSession' in navigator) || !('MediaMetadata' in globalThis)) { + return; + } + + const track = this.getTrack(); + if (!track) { + return; + } + + const cover = await getCover(track.path); + + navigator.mediaSession.metadata = new MediaMetadata({ + title: track.title, + artist: track.artists.join(', '), + album: track.album, + artwork: cover ? [{ src: cover }] : undefined, + }); + } + + /** + * Emit state change event for React hooks + */ + private emitStateChange() { + // Invalidate cache when state changes + this.state = null; + this.emit('stateChange', this.getState()); + } + + /** + * Get current player state snapshot with caching for useSyncExternalStore. + * Returns the same reference when called multiple times without state changes. + */ + getState(): PlayerState { + // Return cached state if available + if (this.state !== null) { + return this.state; + } + + // Create new state object + this.state = { + queue: [...this.queue], + queueCursor: this.queueCursor, + queueOrigin: this.queueOrigin, + repeat: this.repeat, + shuffle: this.shuffle, + volume: this.audio.volume, + muted: this.audio.muted, + isPaused: this.audio.paused, + }; + + return this.state; } + // ============================================================================ + // Playback controls + // ============================================================================ + async play() { - if (!this.audio.src) - throw new Error('Trying to play a track but not audio.src is defined'); + if (!this.audio.src) { + throw new Error('Trying to play a track but no audio.src is defined'); + } await this.audio.play(); } @@ -54,44 +277,313 @@ class Player { stop() { this.audio.pause(); + + // Revoke blob URL if it exists + if (this.blobUrl !== null) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = null; + } + + this.queue = []; + this.queueCursor = null; + this.queueOrigin = null; + this.emit('stop'); + this.emitStateChange(); } - mute() { - this.audio.muted = true; + async playPause() { + try { + if (this.audio.paused && this.queue.length > 0) { + await this.play(); + } else { + this.pause(); + } + } catch (error) { + logAndNotifyError(error); + } } - unmute() { - this.audio.muted = false; + /** + * Jump to next track in queue + */ + async next() { + if (this.queueCursor === null) { + return; + } + + let newQueueCursor: number; + + if (this.repeat === 'One') { + newQueueCursor = this.queueCursor; + } else if ( + this.repeat === 'All' && + this.queueCursor === this.queue.length - 1 + ) { + // Last track with repeat all -> go to first + newQueueCursor = 0; + } else { + newQueueCursor = this.queueCursor + 1; + } + + const track = this.queue[newQueueCursor]; + + if (track !== undefined) { + this.queueCursor = newQueueCursor; + await this.setTrack(track); + await this.play(); + } else { + this.stop(); + } } - getAudio() { - return this.audio; + /** + * Jump to previous track, or restart current track if > 5 seconds + */ + async previous() { + if (this.queueCursor === null) { + return; + } + + let newQueueCursor = this.queueCursor; + + // If track started less than 5 seconds ago, play the previous track + if (this.audio.currentTime < 5) { + newQueueCursor = this.queueCursor - 1; + } + + const track = this.queue[newQueueCursor]; + + if (track !== undefined) { + this.queueCursor = newQueueCursor; + await this.setTrack(track); + await this.play(); + } else { + this.stop(); + } } - getCurrentTime() { - return this.audio.currentTime; + // ============================================================================ + // Queue management + // ============================================================================ + + /** + * Start playing a new queue + */ + async start( + tracks: Track[], + trackID: string | null, + queueOrigin: QueueOrigin, + ) { + if (tracks.length === 0) { + return; + } + + const targetTrackID = trackID ?? tracks[0].id; + const queuePosition = tracks.findIndex((t) => t.id === targetTrackID); + + if (queuePosition === -1) { + return; + } + + let queue = [...tracks]; + let queueCursor = queuePosition; + const oldQueue = [...tracks]; + + // Apply shuffle if enabled + if (this.shuffle) { + queue = shuffleTracks(queue, queueCursor); + queueCursor = 0; + } + + this.queue = queue; + this.oldQueue = oldQueue; + this.queueCursor = queueCursor; + this.queueOrigin = queueOrigin; + + const track = queue[queueCursor]; + await this.setTrack(track); + await this.play().catch(logAndNotifyError); } - getVolume() { - return this.audio.volume; + /** + * Start playing from a specific position in the current queue + */ + async startFromQueue(index: number) { + const track = this.queue[index]; + if (!track) { + return; + } + + this.queueCursor = index; + await this.setTrack(track); + await this.play(); } - setVolume(volume: number) { - this.audio.volume = volume; + /** + * Add tracks to the end of the queue + */ + addToQueue(tracks: Track[]) { + this.queue = [...this.queue, ...tracks]; + + // If queue was empty, set cursor to 0 + if (this.queueCursor === null && tracks.length > 0) { + this.queueCursor = 0; + } + + this.emitStateChange(); + } + + /** + * Add tracks after the currently playing track + */ + addNextInQueue(tracks: Track[]) { + if (this.queueCursor === null) { + this.queue = tracks; + this.queueCursor = 0; + } else { + this.queue.splice(this.queueCursor + 1, 0, ...tracks); + } + + this.emitStateChange(); } - setPlaybackRate(playbackRate: number) { - this.audio.playbackRate = playbackRate; - this.audio.defaultPlaybackRate = playbackRate; + /** + * Remove a track from the queue + */ + removeFromQueue(index: number) { + if (this.queueCursor === null) { + return; + } + + // Convert relative index to absolute + const absoluteIndex = this.queueCursor + index + 1; + this.queue.splice(absoluteIndex, 1); + this.emitStateChange(); + } + + /** + * Clear all tracks after the current one + */ + clearQueue() { + if (this.queueCursor === null) { + return; + } + + this.queue = this.queue.slice(0, this.queueCursor + 1); + this.emitStateChange(); } - getTrack() { - return this.track; + /** + * Set the entire queue (used for reordering) + */ + setQueue(tracks: Track[]) { + this.queue = tracks; + this.emitStateChange(); } - async setTrack(track: Track) { - this.track = track; + /** + * Get the current queue + */ + getQueue(): Track[] { + return [...this.queue]; + } + + /** + * Get queue cursor + */ + getQueueCursor(): number | null { + return this.queueCursor; + } + + /** + * Get queue origin + */ + getQueueOrigin(): QueueOrigin | null { + return this.queueOrigin; + } + + // ============================================================================ + // Shuffle & Repeat + // ============================================================================ + + /** + * Toggle shuffle mode + */ + async toggleShuffle() { + const nextShuffleState = !this.shuffle; + + if (this.queueCursor === null) { + this.shuffle = nextShuffleState; + this.emitStateChange(); + await ConfigBridge.set('audio_shuffle', nextShuffleState); + return; + } + + const trackPlayingID = this.queue[this.queueCursor].id; + if (nextShuffleState) { + // Enable shuffle + const newQueue = shuffleTracks([...this.queue], this.queueCursor); + this.oldQueue = this.queue; + this.queue = newQueue; + this.queueCursor = 0; + } else { + // Disable shuffle - restore original order + const currentTrackIndex = this.oldQueue.findIndex( + (track) => track.id === trackPlayingID, + ); + this.queue = [...this.oldQueue]; + this.queueCursor = currentTrackIndex; + } + + this.shuffle = nextShuffleState; + this.emitStateChange(); + await ConfigBridge.set('audio_shuffle', nextShuffleState); + } + + /** + * Toggle repeat mode (cycles through None -> All -> One -> None) + */ + async toggleRepeat() { + let nextRepeatState: Repeat = 'None'; + + // Cycle through modes + switch (this.repeat) { + case 'None': + nextRepeatState = 'All'; + break; + case 'All': + nextRepeatState = 'One'; + break; + case 'One': + nextRepeatState = 'None'; + break; + } + + this.repeat = nextRepeatState; + this.emitStateChange(); + await ConfigBridge.set('audio_repeat', nextRepeatState); + } + + /** + * Get shuffle state + */ + getShuffle(): boolean { + return this.shuffle; + } + + /** + * Get repeat state + */ + getRepeat(): Repeat { + return this.repeat; + } + + // ============================================================================ + // Track management + // ============================================================================ + + async setTrack(track: Track) { // Revoke previous blob URL if it exists to prevent memory leaks if (this.blobUrl !== null) { URL.revokeObjectURL(this.blobUrl); @@ -104,34 +596,96 @@ class Player { await fetch(convertFileSrc(track.path)).then((res) => res.blob()), ); this.audio.src = this.blobUrl; - return; + } else { + this.audio.src = convertFileSrc(track.path); + } + + this.emit('trackChange', track); + this.emitStateChange(); + } + + /** + * Get the currently playing track (from queue at cursor position) + */ + getTrack(): Track | null { + if (this.queue.length > 0 && this.queueCursor !== null) { + return this.queue[this.queueCursor]; } + return null; + } + + // ============================================================================ + // Audio controls + // ============================================================================ + + setCurrentTime(time: number) { + this.audio.currentTime = time; + } + + getCurrentTime(): number { + return this.audio.currentTime; + } + + setVolume(volume: number) { + this.audio.volume = volume; + this.emitStateChange(); + this.saveVolumeDebounced(volume); + } + + /** + * Debounced volume save to avoid too many writes to config + */ + private readonly saveVolumeDebounced = debounce(async (volume: number) => { + await ConfigBridge.set('audio_volume', volume); + }, 500); - this.audio.src = convertFileSrc(track.path); + getVolume(): number { + return this.audio.volume; } - setCurrentTime(currentTime: number) { - this.audio.currentTime = currentTime; + async toggleMute() { + this.audio.muted = !this.audio.muted; + this.emitStateChange(); + await ConfigBridge.set('audio_muted', this.audio.muted); } - isMuted() { + isMuted(): boolean { return this.audio.muted; } - isPaused() { + isPaused(): boolean { return this.audio.paused; } + + async setPlaybackRate(rate: number) { + // Validate range + if (!Number.isNaN(rate) && rate >= 0.5 && rate <= 5) { + this.audio.playbackRate = rate; + this.audio.defaultPlaybackRate = rate; + this.emitStateChange(); + await ConfigBridge.set('audio_playback_rate', rate); + } else { + this.audio.playbackRate = 1.0; + this.audio.defaultPlaybackRate = 1.0; + this.emitStateChange(); + await ConfigBridge.set('audio_playback_rate', null); + } + } + + getPlaybackRate(): number { + return this.audio.playbackRate; + } } /** - * Export a singleton by default, for the sake of simplicity (and we only need - * one anyway) + * Create and export singleton player instance */ - const playerInstance = new Player({ volume: ConfigBridge.getInitial('audio_volume'), playbackRate: ConfigBridge.getInitial('audio_playback_rate') ?? 1, muted: ConfigBridge.getInitial('audio_muted'), + repeat: ConfigBridge.getInitial('audio_repeat'), + shuffle: ConfigBridge.getInitial('audio_shuffle'), }); export default playerInstance; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 0f41dd43..09797949 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -12,7 +12,6 @@ import Header from '../components/Header'; import IPCNavigationEvents from '../components/IPCNavigationEvents'; import IPCPlayerEvents from '../components/IPCPlayerEvents'; import LibraryEvents from '../components/LibraryEvents'; -import MediaSessionEvents from '../components/MediaSessionEvents'; import PlayerEvents from '../components/PlayerEvents'; import Toasts from '../components/Toasts'; import useInvalidate from '../hooks/useInvalidate'; @@ -66,7 +65,6 @@ function ViewRoot() { - {/** The actual app */} diff --git a/src/routes/artists.tsx b/src/routes/artists.tsx index 90522731..173d5fde 100644 --- a/src/routes/artists.tsx +++ b/src/routes/artists.tsx @@ -11,13 +11,13 @@ import SideNavLink from '../components/SideNavLink'; import TrackListStates from '../components/TrackListStates'; import View from '../elements/View'; import DatabaseBridge from '../lib/bridge-database'; -import usePlayerStore from '../stores/usePlayerStore'; +import player from '../lib/player'; export const Route = createFileRoute('/artists')({ component: ViewArtists, beforeLoad: async ({ params }) => { const artists = await DatabaseBridge.getAllArtists(); - const queueOrigin = usePlayerStore.getState().queueOrigin; + const queueOrigin = player.getQueueOrigin(); if (!('artistID' in params) && artists.length > 0) { // If there is a playing Playlist, redirect to it diff --git a/src/routes/playlists.tsx b/src/routes/playlists.tsx index 68b5f3b7..6c704364 100644 --- a/src/routes/playlists.tsx +++ b/src/routes/playlists.tsx @@ -20,14 +20,14 @@ import View from '../elements/View'; import * as ViewMessage from '../elements/ViewMessage'; import useInvalidate from '../hooks/useInvalidate'; import DatabaseBridge from '../lib/bridge-database'; +import player from '../lib/player'; import PlaylistsAPI from '../stores/PlaylistsAPI'; -import usePlayerStore from '../stores/usePlayerStore'; export const Route = createFileRoute('/playlists')({ component: ViewPlaylists, beforeLoad: async ({ params }) => { const playlists = await DatabaseBridge.getAllPlaylists(); - const queueOrigin = usePlayerStore.getState().queueOrigin; + const queueOrigin = player.getQueueOrigin(); if (!('playlistID' in params) && playlists.length > 0) { // If there is a playing Playlist, redirect to it diff --git a/src/routes/settings.audio.tsx b/src/routes/settings.audio.tsx index f0a0a66e..5aca0e03 100644 --- a/src/routes/settings.audio.tsx +++ b/src/routes/settings.audio.tsx @@ -4,8 +4,8 @@ import { createFileRoute, useLoaderData } from '@tanstack/react-router'; import * as Setting from '../components/Setting'; import CheckboxSetting from '../components/SettingCheckbox'; import useInvalidate, { useInvalidateCallback } from '../hooks/useInvalidate'; +import player from '../lib/player'; import SettingsAPI from '../stores/SettingsAPI'; -import { usePlayerAPI } from '../stores/usePlayerStore'; export const Route = createFileRoute('/settings/audio')({ component: ViewSettingsAudio, @@ -14,7 +14,6 @@ export const Route = createFileRoute('/settings/audio')({ function ViewSettingsAudio() { const { config } = useLoaderData({ from: '/settings' }); - const playerAPI = usePlayerAPI(); const invalidate = useInvalidate(); const { t } = useLingui(); @@ -26,7 +25,7 @@ function ViewSettingsAudio() { description={t`Increase the playback rate: a value of 2 will play your music at a 2x speed`} value={config.audio_playback_rate ?? ''} onChange={(e) => - playerAPI + player .setPlaybackRate(Number.parseFloat(e.currentTarget.value)) .then(invalidate) } diff --git a/src/stores/PlaylistsAPI.ts b/src/stores/PlaylistsAPI.ts index 14659a51..aab65751 100644 --- a/src/stores/PlaylistsAPI.ts +++ b/src/stores/PlaylistsAPI.ts @@ -2,8 +2,8 @@ import { t } from '@lingui/core/macro'; import type { Playlist, Track } from '../generated/typings'; import DatabaseBridge from '../lib/bridge-database'; +import player from '../lib/player'; import { logAndNotifyError } from '../lib/utils'; -import usePlayerStore from './usePlayerStore'; import useToastsStore from './useToastsStore'; /** @@ -13,9 +13,7 @@ async function play(playlistID: string): Promise { try { const playlist = await DatabaseBridge.getPlaylist(playlistID); const tracks = await DatabaseBridge.getTracks(playlist.tracks); - usePlayerStore - .getState() - .api.start(tracks, null, { type: 'playlist', playlistID }); + player.start(tracks, null, { type: 'playlist', playlistID }); } catch (err) { logAndNotifyError(err); } diff --git a/src/stores/SettingsAPI.ts b/src/stores/SettingsAPI.ts index 0b9d4438..f89080e8 100644 --- a/src/stores/SettingsAPI.ts +++ b/src/stores/SettingsAPI.ts @@ -7,10 +7,10 @@ import * as semver from 'semver'; import type { Config } from '../generated/typings'; import ConfigBridge from '../lib/bridge-config'; import { loadTranslation } from '../lib/i18n'; +import player from '../lib/player'; import { getTheme } from '../lib/themes'; import { logAndNotifyError } from '../lib/utils'; import useLibraryStore from './useLibraryStore'; -import usePlayerStore from './usePlayerStore'; import useToastsStore from './useToastsStore'; export const DEFAULT_MAIN_COLOR = '#459ce7'; @@ -60,7 +60,7 @@ async function init(then: () => void): Promise { info( `Starting queue from file associations (${initialQueue.length} tracks)`, ); - usePlayerStore.getState().api.start(initialQueue, initialQueue[0].id, { + player.start(initialQueue, initialQueue[0].id, { type: 'file_associations', }); } diff --git a/src/stores/useLibraryStore.ts b/src/stores/useLibraryStore.ts index acf48a2f..d86a94e3 100644 --- a/src/stores/useLibraryStore.ts +++ b/src/stores/useLibraryStore.ts @@ -5,11 +5,11 @@ import { persist } from 'zustand/middleware'; import type { SortBy, SortOrder } from '../generated/typings'; import ConfigBridge from '../lib/bridge-config'; import DatabaseBridge from '../lib/bridge-database'; +import player from '../lib/player'; import { logAndNotifyError } from '../lib/utils'; import { removeRedundantFolders } from '../lib/utils-library'; import type { API, TrackListStatusInfo, TrackMutation } from '../types/museeks'; import { createStore } from './store-helpers'; -import usePlayerStore from './usePlayerStore'; import useToastsStore from './useToastsStore'; type LibraryState = API<{ @@ -166,7 +166,7 @@ const useLibraryStore = createLibraryStore((set, get) => ({ * Reset the library */ reset: async () => { - usePlayerStore.getState().api.stop(); + player.stop(); try { await DatabaseBridge.reset(); await ConfigBridge.set('library_folders', []); diff --git a/src/stores/usePlayerStore.ts b/src/stores/usePlayerStore.ts deleted file mode 100644 index 3224c716..00000000 --- a/src/stores/usePlayerStore.ts +++ /dev/null @@ -1,535 +0,0 @@ -import debounce from 'lodash-es/debounce'; -import type { StateCreator } from 'zustand'; -import { persist } from 'zustand/middleware'; - -import type { Repeat, Track } from '../generated/typings'; -import ConfigBridge from '../lib/bridge-config'; -import DatabaseBridge from '../lib/bridge-database'; -import player from '../lib/player'; -import { logAndNotifyError } from '../lib/utils'; -import { shuffleTracks } from '../lib/utils-player'; -import { type API, PlayerStatus, type QueueOrigin } from '../types/museeks'; -import { createStore } from './store-helpers'; - -type PlayerState = API<{ - queue: Track[]; - oldQueue: Track[]; - queueCursor: number | null; - queueOrigin: null | QueueOrigin; - repeat: Repeat; - shuffle: boolean; - playerStatus: PlayerStatus; - api: { - start: ( - queue: Track[], - trackID: string | null, - queueOrigin: QueueOrigin, - ) => Promise; - play: () => Promise; - pause: () => void; - playPause: () => Promise; - stop: () => void; - previous: () => Promise; - next: () => Promise; - toggleShuffle: (value?: boolean) => void; - toggleRepeat: (value?: Repeat) => void; - setVolume: (volume: number) => void; - setMuted: (muted: boolean) => void; - setPlaybackRate: (value: number) => Promise; - jumpTo: (to: number) => void; - startFromQueue: (index: number) => Promise; - clearQueue: () => void; - removeFromQueue: (index: number) => void; - addInQueue: (tracksIDs: string[]) => Promise; - addNextInQueue: (tracksIDs: string[]) => Promise; - setQueue: (tracks: Track[]) => void; - }; -}>; - -const usePlayerStore = createPlayerStore((set, get) => ({ - queue: [], // Tracks to be played - oldQueue: [], // Queue backup (in case of shuffle) - queueCursor: null, // The cursor of the queue - queueOrigin: null, // URL of the queue when it was started - repeat: ConfigBridge.getInitial('audio_repeat'), // the current repeat state (one, all, none) - shuffle: ConfigBridge.getInitial('audio_shuffle'), // If shuffle mode is enabled - playerStatus: PlayerStatus.STOP, // Player status - - api: { - /** - * Start playing audio (queue instantiation, shuffle and everything...) - * TODO: this function ~could probably~ needs to be refactored ~a bit~ - */ - start: async (tracks, maybeTrackID, queueOrigin) => { - let queue = tracks; - - if (queue.length === 0) return; - - const state = get(); - - // Check if there's already a queue planned - if (queue === null && state.queue !== null) { - queue = state.queue; - } - - const shuffle = state.shuffle; - - const oldQueue = [...queue]; - const trackID = maybeTrackID ?? queue[0].id; - - // Typically, if we are in the playlists generic view without any view selected - if (queue.length === 0) return; - - const queuePosition = queue.findIndex((track) => track.id === trackID); - - // If a track exists - if (queuePosition > -1) { - const track = queue[queuePosition]; - - await player.setTrack(track); - await player.play().catch(logAndNotifyError); - - let queueCursor = queuePosition; // Clean that variable mess later - - // Check if we have to shuffle the queue - if (shuffle) { - // Shuffle the tracks - queue = shuffleTracks(queue, queueCursor); - // Let's set the cursor to 0 - queueCursor = 0; - } - - set({ - queue, - queueCursor, - queueOrigin, - oldQueue, - playerStatus: PlayerStatus.PLAY, - }); - } - }, - - /** - * Play/resume audio - */ - play: async () => { - // TODO: if there is no queue / no audio set, get the data of the current view - // and start a queue with it - await player.play(); - - set({ playerStatus: PlayerStatus.PLAY }); - }, - - /** - * Pause audio - */ - pause: () => { - player.pause(); - - set({ playerStatus: PlayerStatus.PAUSE }); - }, - - /** - * Toggle play/pause - * FIXME: how to start when player is stopped? - */ - playPause: async () => { - const playerAPI = get().api; - const { queue /* , playerStatus */ } = get(); - const { paused } = player.getAudio(); - - // if (playerStatus === PlayerStatus.STOP) { - // playerAPI.start(tracks); - // } else - if (paused && queue.length > 0) { - playerAPI.play(); - } else { - playerAPI.pause(); - } - }, - - /** - * Stop the player - */ - stop: () => { - player.stop(); - - set({ - queue: [], - queueCursor: null, - playerStatus: PlayerStatus.STOP, - queueOrigin: null, - }); - }, - - /** - * Jump to the next track - */ - next: async () => { - const { queue, queueCursor, repeat } = get(); - let newQueueCursor; - - if (queueCursor !== null) { - if (repeat === 'One') { - newQueueCursor = queueCursor; - } else if (repeat === 'All' && queueCursor === queue.length - 1) { - // is last track - newQueueCursor = 0; // start with new track - } else { - newQueueCursor = queueCursor + 1; - } - - const track = queue[newQueueCursor]; - - if (track !== undefined) { - await player.setTrack(track); - await player.play(); - set({ - playerStatus: PlayerStatus.PLAY, - queueCursor: newQueueCursor, - }); - } else { - get().api.stop(); - } - } - }, - - /** - * Jump to the previous track, or restart the current track after a certain - * treshold - */ - previous: async () => { - const currentTime = player.getCurrentTime(); - - const { queue, queueCursor } = get(); - let newQueueCursor = queueCursor; - - if (queueCursor !== null && newQueueCursor !== null) { - // If track started less than 5 seconds ago, play th previous track, - // otherwise replay the current one - if (currentTime < 5) { - newQueueCursor = queueCursor - 1; - } - - const newTrack = queue[newQueueCursor]; - - if (newTrack !== undefined) { - await player.setTrack(newTrack); - await player.play(); - - set({ - playerStatus: PlayerStatus.PLAY, - queueCursor: newQueueCursor, - }); - } else { - get().api.stop(); - } - } - }, - - /** - * Enable/disable shuffle - */ - toggleShuffle: async (shuffle) => { - const nextShuffleState: boolean = shuffle ?? !get().shuffle; - await ConfigBridge.set('audio_shuffle', nextShuffleState); - - const { queue, queueCursor, oldQueue } = get(); - - if (queueCursor !== null) { - const trackPlayingID = queue[queueCursor].id; - - // If we need to shuffle everything - if (nextShuffleState) { - // Let's shuffle that - const newQueue = shuffleTracks([...queue], queueCursor); - - set({ - queue: newQueue, - queueCursor: 0, - oldQueue: queue, - shuffle: true, - }); - } else { - // Unshuffle the queue by restoring the initial queue - const currentTrackIndex = oldQueue.findIndex( - (track) => trackPlayingID === track.id, - ); - - // Roll back to the old but update queueCursor - set({ - queue: [...oldQueue], - queueCursor: currentTrackIndex, - shuffle: false, - }); - } - } - }, - - /** - * Enable disable repeat - */ - toggleRepeat: async (repeat) => { - let nextRepeatState: Repeat = 'None'; - - // Get to the next repeat type if none is specified - if (repeat === undefined) { - switch (get().repeat) { - case 'None': - nextRepeatState = 'All'; - break; - case 'All': - nextRepeatState = 'One'; - break; - case 'One': - nextRepeatState = 'None'; - break; - } - } - - await ConfigBridge.set('audio_repeat', nextRepeatState); - set({ repeat: nextRepeatState }); - }, - - /** - * Set volume - */ - setVolume: (volume) => { - player.setVolume(volume); - saveVolume(volume); - }, - - /** - * Mute/unmute the audio - */ - setMuted: async (muted = false) => { - if (muted) player.mute(); - else player.unmute(); - - await ConfigBridge.set('audio_muted', muted); - }, - - /** - * Set audio's playback rate - */ - setPlaybackRate: async (value) => { - // if in allowed range - if (!Number.isNaN(value) && value >= 0.5 && value <= 5) { - await ConfigBridge.set('audio_playback_rate', value); - player.setPlaybackRate(value); - } else { - await ConfigBridge.set('audio_playback_rate', null); - player.setPlaybackRate(1.0); - } - }, - - /** - * Jump to a time in the track - */ - jumpTo: (to) => { - player.setCurrentTime(to); - }, - - /** - * Start audio playback from the queue. We don't want to call start() because - * the queue is already set (and we don't want to reshuffle everything and - * all the other checks) - */ - startFromQueue: async (index) => { - const { queue } = get(); - const track = queue[index]; - - await player.setTrack(track); - await player.play(); - - set({ - queue, - queueCursor: index, - playerStatus: PlayerStatus.PLAY, - }); - }, - - /** - * Clear the queue - */ - clearQueue: () => { - const { queueCursor } = get(); - const queue = [...get().queue]; - - if (queueCursor !== null) { - queue.splice(queueCursor + 1, queue.length - queueCursor); - - set({ - queue, - }); - } - }, - - /** - * Remove track from queue - */ - removeFromQueue: (index) => { - const { queueCursor } = get(); - const queue = [...get().queue]; - - if (queueCursor !== null) { - queue.splice(queueCursor + index + 1, 1); - - set({ - queue, - }); - } - }, - - /** - * Add tracks at the end of the queue - */ - addInQueue: async (tracksIDs) => { - const { queue, queueCursor } = get(); - const tracks = await DatabaseBridge.getTracks(tracksIDs); - const newQueue = [...queue, ...tracks]; - - set({ - queue: newQueue, - // Set the queue cursor to zero if there is no current queue - queueCursor: queue.length === 0 ? 0 : queueCursor, - }); - }, - - /** - * Add tracks at the beginning of the queue - */ - addNextInQueue: async (tracksIDs) => { - const tracks = await DatabaseBridge.getTracks(tracksIDs); - - const { queueCursor } = get(); - const queue = [...get().queue]; - - if (queueCursor !== null) { - queue.splice(queueCursor + 1, 0, ...tracks); - set({ - queue, - }); - } else { - set({ - queue, - queueCursor: 0, - }); - } - }, - - /** - * Set the queue - */ - setQueue: (tracks) => { - set({ - queue: tracks, - }); - }, - }, -})); - -export default usePlayerStore; - -export function usePlayerAPI() { - return usePlayerStore((state) => state.api); -} - -// ----------------------------------------------------------------------------- -// Helpers -// ----------------------------------------------------------------------------- - -/** - * Special store for player - */ -function createPlayerStore(store: StateCreator) { - return createStore( - persist(store, { - name: 'museeks-player', - partialize: (state) => { - // on macOS, localStorage is quite limited, so we limit the max number of items - // in the queue by slicing the queue around the currently playing track - // Should oldQueue be tackled in some ways? - const queue = state.queue; - const queueCursor = state.queueCursor ?? 0; - const queueStorageLimit = 1000; - - if (queue.length < queueStorageLimit) { - return state; - } - - const trackID = queue[queueCursor].id; - - const persistedQueue = queue.slice( - Math.max(0, queueCursor - queueStorageLimit / 2), - Math.min(queue.length, queueCursor + queueStorageLimit / 2), - ); - - const persistedCursor = persistedQueue.findIndex( - (track) => track.id === trackID, - ); - - return { - ...state, - queue: persistedQueue, - queueCursor: persistedCursor, - }; - }, - onRehydrateStorage: () => { - return async (state, error) => { - if (error || state == null) { - logAndNotifyError( - error, - 'an error happened during player store hydration', - ); - } else { - // Let's set the player's src and currentTime with the info we have persisted in store - const { queue, queueCursor } = state; - if (queue && queueCursor != null) { - const track = queue[queueCursor]; - await player.setTrack(track); - } - } - }; - }, - merge(persistedState, currentState) { - const stateToPersist = (persistedState ?? { - playerStatus: PlayerStatus.STOP, - }) satisfies Partial; - - const mergedState = { - ...currentState, - ...stateToPersist, - // API should never be persisted - api: currentState.api, - }; - - if (persistedState != null) { - // If player status was playing, set it to pause, as it makes no sense - // to auto-start playing a song when Museeks starts - mergedState.playerStatus = - (persistedState as PlayerState).playerStatus === PlayerStatus.PLAY - ? PlayerStatus.PAUSE - : (persistedState as PlayerState).playerStatus; - - // queueOrigin migration - if ( - 'queueOrigin' in mergedState && - typeof mergedState.queueOrigin === 'string' - ) { - mergedState.queueOrigin = { type: 'library' }; - } - } - - return mergedState; - }, - }), - ); -} - -/** - * Make sure we don't save audio volume to the file system too often - */ -const saveVolume = debounce(async (volume: number) => { - await ConfigBridge.set('audio_volume', volume); -}, 500); diff --git a/src/translations/en.po b/src/translations/en.po index d134d155..e7fe2ff3 100644 --- a/src/translations/en.po +++ b/src/translations/en.po @@ -87,19 +87,19 @@ msgstr "If it happens again, please <0>report an issue" msgid "Queue" msgstr "Queue" -#: src/components/PlayerControls.tsx:19 +#: src/components/PlayerControls.tsx:18 msgid "Previous" msgstr "Previous" -#: src/components/PlayerControls.tsx:26 -msgid "Pause" -msgstr "Pause" - -#: src/components/PlayerControls.tsx:26 +#: src/components/PlayerControls.tsx:25 msgid "Play" msgstr "Play" -#: src/components/PlayerControls.tsx:37 +#: src/components/PlayerControls.tsx:25 +msgid "Pause" +msgstr "Pause" + +#: src/components/PlayerControls.tsx:32 msgid "Next" msgstr "Next" @@ -107,11 +107,11 @@ msgstr "Next" msgid "Queue is empty" msgstr "Queue is empty" -#: src/components/QueueList.tsx:81 +#: src/components/QueueList.tsx:79 msgid "clear queue" msgstr "clear queue" -#: src/components/QueueList.tsx:107 +#: src/components/QueueList.tsx:105 msgid "see more" msgstr "see more" @@ -150,51 +150,51 @@ msgstr "{selectedCount, plural, one {# track selected} other {# tracks selected} msgid "Add to queue" msgstr "Add to queue" -#: src/components/TrackList.tsx:308 +#: src/components/TrackList.tsx:310 msgid "Play next" msgstr "Play next" -#: src/components/TrackList.tsx:318 +#: src/components/TrackList.tsx:322 msgid "Add to playlist" msgstr "Add to playlist" -#: src/components/TrackList.tsx:328 +#: src/components/TrackList.tsx:332 msgid "Search for \"{item}\"" msgstr "Search for \"{item}\"" -#: src/components/TrackList.tsx:341 +#: src/components/TrackList.tsx:345 msgid "Copy \"{item}\"" msgstr "Copy \"{item}\"" -#: src/components/TrackList.tsx:365 +#: src/components/TrackList.tsx:369 msgid "Edit track" msgstr "Edit track" -#: src/components/TrackList.tsx:375 +#: src/components/TrackList.tsx:379 msgid "Show in file manager" msgstr "Show in file manager" -#: src/components/TrackList.tsx:381 +#: src/components/TrackList.tsx:385 msgid "Remove from library" msgstr "Remove from library" #. placeholder {0}: selectedTracks.size -#: src/components/TrackList.tsx:384 +#: src/components/TrackList.tsx:388 msgid "Are you sure you want to remove {0} track(s) from your library?" msgstr "Are you sure you want to remove {0} track(s) from your library?" -#: src/components/TrackList.tsx:386 +#: src/components/TrackList.tsx:390 msgid "Remove tracks" msgstr "Remove tracks" -#: src/components/TrackList.tsx:388 +#: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Cancel" -#: src/components/TrackList.tsx:389 +#: src/components/TrackList.tsx:393 #: src/routes/settings.library.tsx:61 msgid "Remove" msgstr "Remove" @@ -255,8 +255,8 @@ msgstr "you can <0>add your music here" msgid "Your search returned no results" msgstr "Your search returned no results" -#: src/components/VolumeControl.tsx:73 -#: src/components/VolumeControl.tsx:93 +#: src/components/VolumeControl.tsx:56 +#: src/components/VolumeControl.tsx:76 msgid "Volume" msgstr "Volume" @@ -346,19 +346,19 @@ msgstr "Internals" msgid "Open storage directory" msgstr "Open storage directory" -#: src/routes/settings.audio.tsx:25 +#: src/routes/settings.audio.tsx:24 msgid "Playback rate" msgstr "Playback rate" -#: src/routes/settings.audio.tsx:26 +#: src/routes/settings.audio.tsx:25 msgid "Increase the playback rate: a value of 2 will play your music at a 2x speed" msgstr "Increase the playback rate: a value of 2 will play your music at a 2x speed" -#: src/routes/settings.audio.tsx:41 +#: src/routes/settings.audio.tsx:40 msgid "Follow playing track" msgstr "Follow playing track" -#: src/routes/settings.audio.tsx:42 +#: src/routes/settings.audio.tsx:41 msgid "Automatically follow the currently playing track (only when the app is not focused)" msgstr "Automatically follow the currently playing track (only when the app is not focused)" @@ -574,7 +574,7 @@ msgstr "Save" msgid "Clicking \"save\" will only update the library data, but will not save it to the original file." msgstr "Clicking \"save\" will only update the library data, but will not save it to the original file." -#: src/stores/PlaylistsAPI.ts:38 +#: src/stores/PlaylistsAPI.ts:36 msgid "The playlist \"{name}\" was created" msgstr "The playlist \"{name}\" was created" diff --git a/src/translations/fr.po b/src/translations/fr.po index 34febfec..b3fbe7b8 100644 --- a/src/translations/fr.po +++ b/src/translations/fr.po @@ -87,19 +87,19 @@ msgstr "Si cela se produit à nouveau, veuillez <0>signaler un problème" msgid "Queue" msgstr "File d'attente" -#: src/components/PlayerControls.tsx:19 +#: src/components/PlayerControls.tsx:18 msgid "Previous" msgstr "Précédent" -#: src/components/PlayerControls.tsx:26 -msgid "Pause" -msgstr "Pause" - -#: src/components/PlayerControls.tsx:26 +#: src/components/PlayerControls.tsx:25 msgid "Play" msgstr "Lecture" -#: src/components/PlayerControls.tsx:37 +#: src/components/PlayerControls.tsx:25 +msgid "Pause" +msgstr "Pause" + +#: src/components/PlayerControls.tsx:32 msgid "Next" msgstr "Suivant" @@ -107,11 +107,11 @@ msgstr "Suivant" msgid "Queue is empty" msgstr "La file d'attente est vide" -#: src/components/QueueList.tsx:81 +#: src/components/QueueList.tsx:79 msgid "clear queue" msgstr "vider la file d'attente" -#: src/components/QueueList.tsx:107 +#: src/components/QueueList.tsx:105 msgid "see more" msgstr "voir plus" @@ -150,51 +150,51 @@ msgstr "{selectedCount, plural, one {# piste sélectionnée} other {# pistes sé msgid "Add to queue" msgstr "Ajouter à la file d'attente" -#: src/components/TrackList.tsx:308 +#: src/components/TrackList.tsx:310 msgid "Play next" msgstr "Lire ensuite" -#: src/components/TrackList.tsx:318 +#: src/components/TrackList.tsx:322 msgid "Add to playlist" msgstr "Ajouter à une playlist" -#: src/components/TrackList.tsx:328 +#: src/components/TrackList.tsx:332 msgid "Search for \"{item}\"" msgstr "Rechercher \"{item}\"" -#: src/components/TrackList.tsx:341 +#: src/components/TrackList.tsx:345 msgid "Copy \"{item}\"" msgstr "Copier \"{item}\"" -#: src/components/TrackList.tsx:365 +#: src/components/TrackList.tsx:369 msgid "Edit track" msgstr "Modifier la piste" -#: src/components/TrackList.tsx:375 +#: src/components/TrackList.tsx:379 msgid "Show in file manager" msgstr "Montrer dans le gestionnaire de fichiers" -#: src/components/TrackList.tsx:381 +#: src/components/TrackList.tsx:385 msgid "Remove from library" msgstr "Retirer de la bibliothèque" #. placeholder {0}: selectedTracks.size -#: src/components/TrackList.tsx:384 +#: src/components/TrackList.tsx:388 msgid "Are you sure you want to remove {0} track(s) from your library?" msgstr "Êtes-vous sûr de vouloir retirer {0} piste(s) de votre bibliothèque ?" -#: src/components/TrackList.tsx:386 +#: src/components/TrackList.tsx:390 msgid "Remove tracks" -msgstr "Retirer les pistes<<<<<<< HEAD" +msgstr "Retirer les pistes" -#: src/components/TrackList.tsx:388 +#: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Annuler" -#: src/components/TrackList.tsx:389 +#: src/components/TrackList.tsx:393 #: src/routes/settings.library.tsx:61 msgid "Remove" msgstr "Retirer" @@ -255,8 +255,8 @@ msgstr "vous pouvez <0>ajouter votre musique ici" msgid "Your search returned no results" msgstr "Votre recherche n'a retourné aucun résultat" -#: src/components/VolumeControl.tsx:73 -#: src/components/VolumeControl.tsx:93 +#: src/components/VolumeControl.tsx:56 +#: src/components/VolumeControl.tsx:76 msgid "Volume" msgstr "Volume" @@ -346,19 +346,19 @@ msgstr "Détails techniques" msgid "Open storage directory" msgstr "Ouvrir le dossier de stockage" -#: src/routes/settings.audio.tsx:25 +#: src/routes/settings.audio.tsx:24 msgid "Playback rate" msgstr "Vitesse de lecture" -#: src/routes/settings.audio.tsx:26 +#: src/routes/settings.audio.tsx:25 msgid "Increase the playback rate: a value of 2 will play your music at a 2x speed" msgstr "Augmenter la vitesse de lecture : une valeur de 2 jouera votre musique à une vitesse 2x" -#: src/routes/settings.audio.tsx:41 +#: src/routes/settings.audio.tsx:40 msgid "Follow playing track" msgstr "Suivre la piste en cours" -#: src/routes/settings.audio.tsx:42 +#: src/routes/settings.audio.tsx:41 msgid "Automatically follow the currently playing track (only when the app is not focused)" msgstr "Suivre automatiquement la piste en cours (uniquement lorsque l'application n'a pas le focus)" @@ -574,7 +574,7 @@ msgstr "Enregistrer" msgid "Clicking \"save\" will only update the library data, but will not save it to the original file." msgstr "Cliquer sur \"enregistrer\" mettra uniquement à jour les données de la bibliothèque, mais ne les enregistrera pas dans le fichier d'origine." -#: src/stores/PlaylistsAPI.ts:38 +#: src/stores/PlaylistsAPI.ts:36 msgid "The playlist \"{name}\" was created" msgstr "La playlist \"{name}\" a été créée" diff --git a/src/translations/ja.po b/src/translations/ja.po index 6e98e8c5..f673f3f4 100644 --- a/src/translations/ja.po +++ b/src/translations/ja.po @@ -87,19 +87,19 @@ msgstr "再度発生した場合は、<0>問題を報告してください" msgid "Queue" msgstr "キュー" -#: src/components/PlayerControls.tsx:19 +#: src/components/PlayerControls.tsx:18 msgid "Previous" msgstr "前へ" -#: src/components/PlayerControls.tsx:26 -msgid "Pause" -msgstr "一時停止" - -#: src/components/PlayerControls.tsx:26 +#: src/components/PlayerControls.tsx:25 msgid "Play" msgstr "再生" -#: src/components/PlayerControls.tsx:37 +#: src/components/PlayerControls.tsx:25 +msgid "Pause" +msgstr "一時停止" + +#: src/components/PlayerControls.tsx:32 msgid "Next" msgstr "次へ" @@ -107,11 +107,11 @@ msgstr "次へ" msgid "Queue is empty" msgstr "キューは空です" -#: src/components/QueueList.tsx:81 +#: src/components/QueueList.tsx:79 msgid "clear queue" msgstr "キューをクリア" -#: src/components/QueueList.tsx:107 +#: src/components/QueueList.tsx:105 msgid "see more" msgstr "もっと見る" @@ -150,51 +150,51 @@ msgstr "{selectedCount, plural, one {# 曲が選択されています} other {# msgid "Add to queue" msgstr "キューに追加" -#: src/components/TrackList.tsx:308 +#: src/components/TrackList.tsx:310 msgid "Play next" msgstr "次に再生" -#: src/components/TrackList.tsx:318 +#: src/components/TrackList.tsx:322 msgid "Add to playlist" msgstr "プレイリストに追加" -#: src/components/TrackList.tsx:328 +#: src/components/TrackList.tsx:332 msgid "Search for \"{item}\"" msgstr "「{item}」を検索" -#: src/components/TrackList.tsx:341 +#: src/components/TrackList.tsx:345 msgid "Copy \"{item}\"" msgstr "「{item}」をコピー" -#: src/components/TrackList.tsx:365 +#: src/components/TrackList.tsx:369 msgid "Edit track" msgstr "トラックを編集" -#: src/components/TrackList.tsx:375 +#: src/components/TrackList.tsx:379 msgid "Show in file manager" msgstr "ファイルマネージャーで表示" -#: src/components/TrackList.tsx:381 +#: src/components/TrackList.tsx:385 msgid "Remove from library" msgstr "ライブラリから削除" #. placeholder {0}: selectedTracks.size -#: src/components/TrackList.tsx:384 +#: src/components/TrackList.tsx:388 msgid "Are you sure you want to remove {0} track(s) from your library?" msgstr "ライブラリから {0} 曲を削除してもよろしいですか?" -#: src/components/TrackList.tsx:386 +#: src/components/TrackList.tsx:390 msgid "Remove tracks" msgstr "トラックを削除" -#: src/components/TrackList.tsx:388 +#: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "キャンセル" -#: src/components/TrackList.tsx:389 +#: src/components/TrackList.tsx:393 #: src/routes/settings.library.tsx:61 msgid "Remove" msgstr "削除" @@ -255,8 +255,8 @@ msgstr "<0>ここから音楽を追加できます" msgid "Your search returned no results" msgstr "検索結果が見つかりませんでした" -#: src/components/VolumeControl.tsx:73 -#: src/components/VolumeControl.tsx:93 +#: src/components/VolumeControl.tsx:56 +#: src/components/VolumeControl.tsx:76 msgid "Volume" msgstr "音量" @@ -346,19 +346,19 @@ msgstr "内部情報" msgid "Open storage directory" msgstr "ストレージディレクトリを開く" -#: src/routes/settings.audio.tsx:25 +#: src/routes/settings.audio.tsx:24 msgid "Playback rate" msgstr "再生速度" -#: src/routes/settings.audio.tsx:26 +#: src/routes/settings.audio.tsx:25 msgid "Increase the playback rate: a value of 2 will play your music at a 2x speed" msgstr "再生速度を上げる:値が2の場合、音楽が2倍速で再生されます" -#: src/routes/settings.audio.tsx:41 +#: src/routes/settings.audio.tsx:40 msgid "Follow playing track" msgstr "再生中のトラックを追従" -#: src/routes/settings.audio.tsx:42 +#: src/routes/settings.audio.tsx:41 msgid "Automatically follow the currently playing track (only when the app is not focused)" msgstr "現在再生中のトラックを自動的に追従(アプリがフォーカスされていない時のみ)" @@ -574,7 +574,7 @@ msgstr "保存" msgid "Clicking \"save\" will only update the library data, but will not save it to the original file." msgstr "「保存」をクリックするとライブラリデータのみが更新され、元のファイルには保存されません。" -#: src/stores/PlaylistsAPI.ts:38 +#: src/stores/PlaylistsAPI.ts:36 msgid "The playlist \"{name}\" was created" msgstr "プレイリスト「{name}」が作成されました" diff --git a/src/translations/ru.po b/src/translations/ru.po index 7e92cc69..8bd5dfed 100644 --- a/src/translations/ru.po +++ b/src/translations/ru.po @@ -87,19 +87,19 @@ msgstr "Если это случится снова, пожалуйста, <0>з msgid "Queue" msgstr "Очередь" -#: src/components/PlayerControls.tsx:19 +#: src/components/PlayerControls.tsx:18 msgid "Previous" msgstr "Предыдущий" -#: src/components/PlayerControls.tsx:26 -msgid "Pause" -msgstr "Пауза" - -#: src/components/PlayerControls.tsx:26 +#: src/components/PlayerControls.tsx:25 msgid "Play" msgstr "Плей" -#: src/components/PlayerControls.tsx:37 +#: src/components/PlayerControls.tsx:25 +msgid "Pause" +msgstr "Пауза" + +#: src/components/PlayerControls.tsx:32 msgid "Next" msgstr "Следующий" @@ -107,11 +107,11 @@ msgstr "Следующий" msgid "Queue is empty" msgstr "Очередь пуста" -#: src/components/QueueList.tsx:81 +#: src/components/QueueList.tsx:79 msgid "clear queue" msgstr "очистить очередь" -#: src/components/QueueList.tsx:107 +#: src/components/QueueList.tsx:105 msgid "see more" msgstr "показать ещё" @@ -150,51 +150,51 @@ msgstr "Выбран{selectedCount, plural, one { # трек} few {ы # трек msgid "Add to queue" msgstr "Добавить в очередь" -#: src/components/TrackList.tsx:308 +#: src/components/TrackList.tsx:310 msgid "Play next" msgstr "Играть следующим" -#: src/components/TrackList.tsx:318 +#: src/components/TrackList.tsx:322 msgid "Add to playlist" msgstr "Добавить в плейлист" -#: src/components/TrackList.tsx:328 +#: src/components/TrackList.tsx:332 msgid "Search for \"{item}\"" msgstr "Искать \"{item}\"" -#: src/components/TrackList.tsx:341 +#: src/components/TrackList.tsx:345 msgid "Copy \"{item}\"" msgstr "Скопировать \"{item}\"" -#: src/components/TrackList.tsx:365 +#: src/components/TrackList.tsx:369 msgid "Edit track" msgstr "Редактировать трек" -#: src/components/TrackList.tsx:375 +#: src/components/TrackList.tsx:379 msgid "Show in file manager" msgstr "Открыть в файловом менеджере" -#: src/components/TrackList.tsx:381 +#: src/components/TrackList.tsx:385 msgid "Remove from library" msgstr "Удалить из библиотеки" #. placeholder {0}: selectedTracks.size -#: src/components/TrackList.tsx:384 +#: src/components/TrackList.tsx:388 msgid "Are you sure you want to remove {0} track(s) from your library?" msgstr "Вы уверены, что хотите удалить {0} трек(ов) из вашей библиотеки?" -#: src/components/TrackList.tsx:386 +#: src/components/TrackList.tsx:390 msgid "Remove tracks" msgstr "Удалить треки" -#: src/components/TrackList.tsx:388 +#: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "Отмена" -#: src/components/TrackList.tsx:389 +#: src/components/TrackList.tsx:393 #: src/routes/settings.library.tsx:61 msgid "Remove" msgstr "Удалить" @@ -255,8 +255,8 @@ msgstr "вы можете добавить <0>вашу музыку здесь报告问题" msgid "Queue" msgstr "播放队列" -#: src/components/PlayerControls.tsx:19 +#: src/components/PlayerControls.tsx:18 msgid "Previous" msgstr "上一首" -#: src/components/PlayerControls.tsx:26 -msgid "Pause" -msgstr "暂停" - -#: src/components/PlayerControls.tsx:26 +#: src/components/PlayerControls.tsx:25 msgid "Play" msgstr "播放" -#: src/components/PlayerControls.tsx:37 +#: src/components/PlayerControls.tsx:25 +msgid "Pause" +msgstr "暂停" + +#: src/components/PlayerControls.tsx:32 msgid "Next" msgstr "下一首" @@ -107,11 +107,11 @@ msgstr "下一首" msgid "Queue is empty" msgstr "播放队列为空" -#: src/components/QueueList.tsx:81 +#: src/components/QueueList.tsx:79 msgid "clear queue" msgstr "清空队列" -#: src/components/QueueList.tsx:107 +#: src/components/QueueList.tsx:105 msgid "see more" msgstr "查看更多" @@ -150,51 +150,51 @@ msgstr "{selectedCount, plural, one {# 首音轨已选择} other {# 首音轨已 msgid "Add to queue" msgstr "添加到队列" -#: src/components/TrackList.tsx:308 +#: src/components/TrackList.tsx:310 msgid "Play next" msgstr "下一个播放" -#: src/components/TrackList.tsx:318 +#: src/components/TrackList.tsx:322 msgid "Add to playlist" msgstr "添加到播放列表" -#: src/components/TrackList.tsx:328 +#: src/components/TrackList.tsx:332 msgid "Search for \"{item}\"" msgstr "搜索 \"{item}\"" -#: src/components/TrackList.tsx:341 +#: src/components/TrackList.tsx:345 msgid "Copy \"{item}\"" msgstr "复制 \"{item}\"" -#: src/components/TrackList.tsx:365 +#: src/components/TrackList.tsx:369 msgid "Edit track" msgstr "编辑音轨" -#: src/components/TrackList.tsx:375 +#: src/components/TrackList.tsx:379 msgid "Show in file manager" msgstr "在文件管理器中显示" -#: src/components/TrackList.tsx:381 +#: src/components/TrackList.tsx:385 msgid "Remove from library" msgstr "从音乐库中移除" #. placeholder {0}: selectedTracks.size -#: src/components/TrackList.tsx:384 +#: src/components/TrackList.tsx:388 msgid "Are you sure you want to remove {0} track(s) from your library?" msgstr "您确定要从音乐库中移除 {0} 首音轨吗?" -#: src/components/TrackList.tsx:386 +#: src/components/TrackList.tsx:390 msgid "Remove tracks" msgstr "移除音轨" -#: src/components/TrackList.tsx:388 +#: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "取消" -#: src/components/TrackList.tsx:389 +#: src/components/TrackList.tsx:393 #: src/routes/settings.library.tsx:61 msgid "Remove" msgstr "移除" @@ -255,8 +255,8 @@ msgstr "您可以<0>在这里添加音乐" msgid "Your search returned no results" msgstr "您的搜索没有返回结果" -#: src/components/VolumeControl.tsx:73 -#: src/components/VolumeControl.tsx:93 +#: src/components/VolumeControl.tsx:56 +#: src/components/VolumeControl.tsx:76 msgid "Volume" msgstr "音量" @@ -346,19 +346,19 @@ msgstr "内部信息" msgid "Open storage directory" msgstr "打开存储目录" -#: src/routes/settings.audio.tsx:25 +#: src/routes/settings.audio.tsx:24 msgid "Playback rate" msgstr "播放速率" -#: src/routes/settings.audio.tsx:26 +#: src/routes/settings.audio.tsx:25 msgid "Increase the playback rate: a value of 2 will play your music at a 2x speed" msgstr "增加播放速率:值为 2 将以 2 倍速播放您的音乐" -#: src/routes/settings.audio.tsx:41 +#: src/routes/settings.audio.tsx:40 msgid "Follow playing track" msgstr "跟随播放音轨" -#: src/routes/settings.audio.tsx:42 +#: src/routes/settings.audio.tsx:41 msgid "Automatically follow the currently playing track (only when the app is not focused)" msgstr "自动跟随当前播放的音轨(仅在应用未聚焦时)" @@ -574,7 +574,7 @@ msgstr "保存" msgid "Clicking \"save\" will only update the library data, but will not save it to the original file." msgstr "点击\"保存\"只会更新音乐库数据,但不会保存到原始文件。" -#: src/stores/PlaylistsAPI.ts:38 +#: src/stores/PlaylistsAPI.ts:36 msgid "The playlist \"{name}\" was created" msgstr "播放列表 \"{name}\" 已创建" diff --git a/src/translations/zh-TW.po b/src/translations/zh-TW.po index 490459ee..810784a4 100644 --- a/src/translations/zh-TW.po +++ b/src/translations/zh-TW.po @@ -87,19 +87,19 @@ msgstr "如果再次發生,請<0>回報問題" msgid "Queue" msgstr "播放佇列" -#: src/components/PlayerControls.tsx:19 +#: src/components/PlayerControls.tsx:18 msgid "Previous" msgstr "上一首" -#: src/components/PlayerControls.tsx:26 -msgid "Pause" -msgstr "暫停" - -#: src/components/PlayerControls.tsx:26 +#: src/components/PlayerControls.tsx:25 msgid "Play" msgstr "播放" -#: src/components/PlayerControls.tsx:37 +#: src/components/PlayerControls.tsx:25 +msgid "Pause" +msgstr "暫停" + +#: src/components/PlayerControls.tsx:32 msgid "Next" msgstr "下一首" @@ -107,11 +107,11 @@ msgstr "下一首" msgid "Queue is empty" msgstr "播放佇列為空" -#: src/components/QueueList.tsx:81 +#: src/components/QueueList.tsx:79 msgid "clear queue" msgstr "清空佇列" -#: src/components/QueueList.tsx:107 +#: src/components/QueueList.tsx:105 msgid "see more" msgstr "檢視更多" @@ -150,51 +150,51 @@ msgstr "{selectedCount, plural, one {# 首音軌已選擇} other {# 首音軌已 msgid "Add to queue" msgstr "新增到佇列" -#: src/components/TrackList.tsx:308 +#: src/components/TrackList.tsx:310 msgid "Play next" msgstr "接著播放" -#: src/components/TrackList.tsx:318 +#: src/components/TrackList.tsx:322 msgid "Add to playlist" msgstr "新增到播放清單" -#: src/components/TrackList.tsx:328 +#: src/components/TrackList.tsx:332 msgid "Search for \"{item}\"" msgstr "搜尋 \"{item}\"" -#: src/components/TrackList.tsx:341 +#: src/components/TrackList.tsx:345 msgid "Copy \"{item}\"" msgstr "複製 \"{item}\"" -#: src/components/TrackList.tsx:365 +#: src/components/TrackList.tsx:369 msgid "Edit track" msgstr "編輯音軌" -#: src/components/TrackList.tsx:375 +#: src/components/TrackList.tsx:379 msgid "Show in file manager" msgstr "在檔案管理員中顯示" -#: src/components/TrackList.tsx:381 +#: src/components/TrackList.tsx:385 msgid "Remove from library" msgstr "從音樂庫中移除" #. placeholder {0}: selectedTracks.size -#: src/components/TrackList.tsx:384 +#: src/components/TrackList.tsx:388 msgid "Are you sure you want to remove {0} track(s) from your library?" msgstr "您確定要從音樂庫中移除 {0} 首音軌嗎?" -#: src/components/TrackList.tsx:386 +#: src/components/TrackList.tsx:390 msgid "Remove tracks" msgstr "移除音軌" -#: src/components/TrackList.tsx:388 +#: src/components/TrackList.tsx:392 #: src/routes/settings.library.tsx:98 #: src/routes/settings.library.tsx:143 #: src/routes/tracks.$trackID.tsx:246 msgid "Cancel" msgstr "取消" -#: src/components/TrackList.tsx:389 +#: src/components/TrackList.tsx:393 #: src/routes/settings.library.tsx:61 msgid "Remove" msgstr "移除" @@ -255,8 +255,8 @@ msgstr "您可以<0>在這裡新增音樂" msgid "Your search returned no results" msgstr "您的搜尋沒有找到結果" -#: src/components/VolumeControl.tsx:73 -#: src/components/VolumeControl.tsx:93 +#: src/components/VolumeControl.tsx:56 +#: src/components/VolumeControl.tsx:76 msgid "Volume" msgstr "音量" @@ -346,19 +346,19 @@ msgstr "內部資訊" msgid "Open storage directory" msgstr "開啟儲存目錄" -#: src/routes/settings.audio.tsx:25 +#: src/routes/settings.audio.tsx:24 msgid "Playback rate" msgstr "播放速率" -#: src/routes/settings.audio.tsx:26 +#: src/routes/settings.audio.tsx:25 msgid "Increase the playback rate: a value of 2 will play your music at a 2x speed" msgstr "增加播放速率:值為 2 將以 2 倍速播放您的音樂" -#: src/routes/settings.audio.tsx:41 +#: src/routes/settings.audio.tsx:40 msgid "Follow playing track" msgstr "跟隨播放音軌" -#: src/routes/settings.audio.tsx:42 +#: src/routes/settings.audio.tsx:41 msgid "Automatically follow the currently playing track (only when the app is not focused)" msgstr "自動跟隨正在播放的音軌(僅在應用程式未聚焦時)" @@ -574,7 +574,7 @@ msgstr "儲存" msgid "Clicking \"save\" will only update the library data, but will not save it to the original file." msgstr "點擊\"儲存\"只會更新音樂庫資料,但不會儲存到原始檔案。" -#: src/stores/PlaylistsAPI.ts:38 +#: src/stores/PlaylistsAPI.ts:36 msgid "The playlist \"{name}\" was created" msgstr "播放清單 \"{name}\" 已建立" diff --git a/src/types/museeks.ts b/src/types/museeks.ts index dee5870d..b6b37512 100644 --- a/src/types/museeks.ts +++ b/src/types/museeks.ts @@ -1,14 +1,5 @@ import type { Track } from '../generated/typings'; -/** - * Player related stuff - */ -export enum PlayerStatus { - PLAY = 'play', - PAUSE = 'pause', - STOP = 'stop', -} - /** * Various */