This commit is contained in:
2026-06-20 17:28:17 -07:00
parent e55a825e13
commit f2ce3e82fa
7 changed files with 158 additions and 10 deletions
@@ -112,8 +112,11 @@
function buildDesktopMpvUrl(url, metadata) { function buildDesktopMpvUrl(url, metadata) {
const encodedUrl = encodeUrlSafeBase64(url) const encodedUrl = encodeUrlSafeBase64(url)
const encodedTitle = metadata.title ? encodeUrlSafeBase64(metadata.title) : '' const queryParts = ['enqueue=append']
const query = encodedTitle ? `?v_title=${encodedTitle}` : '' if (metadata.title) {
queryParts.push(`v_title=${encodeUrlSafeBase64(metadata.title)}`)
}
const query = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''
return `mpv-handler://play/${encodedUrl}/${query}` return `mpv-handler://play/${encodedUrl}/${query}`
} }
+10
View File
@@ -59,6 +59,16 @@ npm run setup:mpv-handler -- --skip-config
npm run setup:mpv-handler -- --dry-run npm run setup:mpv-handler -- --dry-run
``` ```
## Reuse existing mpv window (playlist append)
Desktop links now include an `enqueue=append` hint. For the bundled Linux Python fallback handler, this can append into an already-running mpv instance when you set an IPC socket in `config.toml`:
```toml
ipc_socket = "/tmp/mpv-handler.sock"
```
With this set, the first launch starts mpv with that IPC socket and later launches can add new URLs to the current playlist instead of opening a new window.
`--root` points at the extracted upstream `mpv-handler` folder. On Windows it is optional because this repo already ships the handler files under `scripts/`. On Linux it is normally required. `--root` points at the extracted upstream `mpv-handler` folder. On Windows it is optional because this repo already ships the handler files under `scripts/`. On Linux it is normally required.
## What the helper does ## What the helper does
+5
View File
@@ -13,6 +13,11 @@
# Optional, Type: String # Optional, Type: String
# HTTP(S) proxy server address # HTTP(S) proxy server address
#ipc_socket = "/tmp/mpv-handler.sock"
# Optional, Type: String
# mpv IPC socket path used for appending new URLs to an already-running mpv instance.
# If set, API Media Player desktop links can enqueue into the existing player instead of opening a new window.
# For Windows users: # For Windows users:
# - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe" # - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe"
# - The path is an executable binary file, not a directory # - The path is an executable binary file, not a directory
+54 -3
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import base64 import base64
import json
import os import os
import re import re
import socket
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -33,15 +35,57 @@ def resolve_mpv_command(config_path: Path) -> list[str]:
return ['mpv'] return ['mpv']
def get_ipc_socket_path(config_path: Path) -> str:
configured_socket = read_config_value(config_path, 'ipc_socket')
if configured_socket:
return configured_socket
return os.environ.get('MPV_HANDLER_IPC_SOCKET', '').strip()
def try_enqueue_via_ipc(ipc_socket_path: str, target_url: str, title: str) -> bool:
if not ipc_socket_path:
return False
socket_path = Path(ipc_socket_path).expanduser()
if not socket_path.exists():
return False
# Try with per-entry title first, then fallback to plain append if unsupported.
commands = [
{'command': ['loadfile', target_url, 'append-play', f'force-media-title={title}']} if title else {'command': ['loadfile', target_url, 'append-play']},
{'command': ['loadfile', target_url, 'append-play']},
]
for command in commands:
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(0.4)
client.connect(str(socket_path))
client.sendall((json.dumps(command) + '\n').encode('utf-8'))
return True
except Exception:
continue
return False
def build_mpv_command(target_url: str, title: str, config_path: Path) -> list[str]: def build_mpv_command(target_url: str, title: str, config_path: Path) -> list[str]:
command = resolve_mpv_command(config_path) command = resolve_mpv_command(config_path)
ipc_socket_path = get_ipc_socket_path(config_path)
if ipc_socket_path:
expanded_socket = str(Path(ipc_socket_path).expanduser())
# Ensure new mpv instances expose the same IPC socket for future append requests.
command.append(f'--input-ipc-server={expanded_socket}')
if title: if title:
command.append(f'--force-media-title={title}') command.append(f'--force-media-title={title}')
command.append(target_url) command.append(target_url)
return command return command
def parse_protocol_url(raw_url: str) -> tuple[str, str]: def parse_protocol_url(raw_url: str) -> tuple[str, str, str]:
parsed = urlparse(raw_url) parsed = urlparse(raw_url)
if parsed.scheme not in {'mpv-handler', 'mpv-handler-debug'}: if parsed.scheme not in {'mpv-handler', 'mpv-handler-debug'}:
raise ValueError(f'Unsupported scheme: {parsed.scheme}') raise ValueError(f'Unsupported scheme: {parsed.scheme}')
@@ -57,7 +101,8 @@ def parse_protocol_url(raw_url: str) -> tuple[str, str]:
title = '' title = ''
if query.get('v_title'): if query.get('v_title'):
title = decode_urlsafe_base64(query['v_title'][0]) title = decode_urlsafe_base64(query['v_title'][0])
return target_url, title enqueue_mode = (query.get('enqueue') or [''])[0].strip().lower()
return target_url, title, enqueue_mode
def main() -> int: def main() -> int:
@@ -66,7 +111,7 @@ def main() -> int:
return 1 return 1
try: try:
target_url, title = parse_protocol_url(sys.argv[1]) target_url, title, enqueue_mode = parse_protocol_url(sys.argv[1])
except Exception as error: except Exception as error:
print(f'Failed to parse mpv-handler URL: {error}', file=sys.stderr) print(f'Failed to parse mpv-handler URL: {error}', file=sys.stderr)
return 1 return 1
@@ -74,6 +119,12 @@ def main() -> int:
config_home = os.environ.get('XDG_CONFIG_HOME', '').strip() config_home = os.environ.get('XDG_CONFIG_HOME', '').strip()
config_dir = Path(config_home) / 'mpv-handler' if config_home else Path.home() / '.config' / 'mpv-handler' config_dir = Path(config_home) / 'mpv-handler' if config_home else Path.home() / '.config' / 'mpv-handler'
config_path = config_dir / 'config.toml' config_path = config_dir / 'config.toml'
if enqueue_mode == 'append':
ipc_socket_path = get_ipc_socket_path(config_path)
if try_enqueue_via_ipc(ipc_socket_path, target_url, title):
return 0
command = build_mpv_command(target_url, title, config_path) command = build_mpv_command(target_url, title, config_path)
try: try:
+3 -1
View File
@@ -200,7 +200,9 @@ function buildAndroidMpvIntentUrl(track: Track) {
function buildDesktopMpvHandlerUrl(track: Track) { function buildDesktopMpvHandlerUrl(track: Track) {
const metadata = buildExternalPlayerMetadata(track) const metadata = buildExternalPlayerMetadata(track)
const encodedUrl = encodeUrlSafeBase64(track.url) const encodedUrl = encodeUrlSafeBase64(track.url)
const query = metadata.title ? `?v_title=${encodeUrlSafeBase64(metadata.title)}` : '' const queryParts = ['enqueue=append']
if (metadata.title) queryParts.push(`v_title=${encodeUrlSafeBase64(metadata.title)}`)
const query = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''
return `mpv-handler://play/${encodedUrl}/${query}` return `mpv-handler://play/${encodedUrl}/${query}`
} }
+81 -4
View File
@@ -71,6 +71,24 @@ const STREAMABLE_FILE_EXTENSIONS = new Set([
'flv', 'flv',
]) ])
const MIME_EXTENSION_FALLBACKS: Array<[string, string]> = [
['audio/flac', 'flac'],
['audio/x-flac', 'flac'],
['audio/mpeg', 'mp3'],
['audio/mp3', 'mp3'],
['audio/mp4', 'm4a'],
['audio/aac', 'aac'],
['audio/wav', 'wav'],
['audio/x-wav', 'wav'],
['audio/ogg', 'ogg'],
['audio/opus', 'opus'],
['video/mp4', 'mp4'],
['video/webm', 'webm'],
['video/x-matroska', 'mkv'],
['video/quicktime', 'mov'],
['application/vnd.apple.mpegurl', 'm3u8'],
]
type SortOption = { type SortOption = {
id: SortField id: SortField
label: string label: string
@@ -373,6 +391,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const [visibleCount, setVisibleCount] = useState(RESULTS_PAGE_SIZE) const [visibleCount, setVisibleCount] = useState(RESULTS_PAGE_SIZE)
const theme = useTheme() const theme = useTheme()
const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm')) const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm'))
const isMobileDialogLayout = useMediaQuery(theme.breakpoints.down('md'))
const { servers, onlineServerIds, healthChecksComplete } = useServers() const { servers, onlineServerIds, healthChecksComplete } = useServers()
const hasServers = servers.length > 0 const hasServers = servers.length > 0
@@ -604,6 +623,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (tokens.length === 0) return true if (tokens.length === 0) return true
return tokens.every((token) => { return tokens.every((token) => {
const namespaceMatch = token.match(SEARCH_NAMESPACE_PATTERN)
if (namespaceMatch) {
return matchesNamespacedClause(track, namespaceMatch[1].toLowerCase(), namespaceMatch[2])
}
const term = normalizeSearchTerm(token) const term = normalizeSearchTerm(token)
if (!term) return true if (!term) return true
@@ -640,7 +664,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
if (clauses.length === 0) return true if (clauses.length === 0) return true
return clauses.every((clause) => { return clauses.every((clause) => {
const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN) const namespaceMatch = !/\s/.test(clause) ? clause.match(SEARCH_NAMESPACE_PATTERN) : null
if (namespaceMatch) { if (namespaceMatch) {
const namespace = namespaceMatch[1].toLowerCase() const namespace = namespaceMatch[1].toLowerCase()
const rawValue = namespaceMatch[2] const rawValue = namespaceMatch[2]
@@ -701,9 +725,22 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
} }
function getTrackExtension(track: Track, details?: HydrusFileDetails | null) { function getTrackExtension(track: Track, details?: HydrusFileDetails | null) {
if (details?.extension) return details.extension const detailsExtension = (details?.extension || '').trim().replace(/^\./, '').toLowerCase()
if (detailsExtension) return detailsExtension
const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i) const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i)
return match ? match[1].toLowerCase() : undefined if (match) return match[1].toLowerCase()
const normalizedMimeType = (track.mimeType || '').trim().toLowerCase()
if (!normalizedMimeType) return undefined
for (const [mime, extension] of MIME_EXTENSION_FALLBACKS) {
if (normalizedMimeType.startsWith(mime)) return extension
}
const mimeSuffix = normalizedMimeType.match(/^[a-z0-9.+-]+\/([a-z0-9.+-]+)/)
if (!mimeSuffix?.[1]) return undefined
return mimeSuffix[1].replace(/^x-/, '').split('+')[0]
} }
function canStreamTrack(track: Track, details?: HydrusFileDetails | null) { function canStreamTrack(track: Track, details?: HydrusFileDetails | null) {
@@ -1087,6 +1124,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
const handleDownloadTrack = () => { const handleDownloadTrack = () => {
if (!detailsTrack?.url || detailsTrackDownloading) return if (!detailsTrack?.url || detailsTrackDownloading) return
closeDetails()
onDownloadTrack(detailsTrack, detailsData) onDownloadTrack(detailsTrack, detailsData)
} }
@@ -1184,6 +1222,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
return visibleResults return visibleResults
}, [shouldShowGroupedTracks, visibleTrackGroups, visibleResults]) }, [shouldShowGroupedTracks, visibleTrackGroups, visibleResults])
useEffect(() => {
if (!detailsOpen || !isMobileDialogLayout || typeof document === 'undefined') return
const viewportMeta = document.querySelector<HTMLMetaElement>('meta[name="viewport"]')
if (!viewportMeta) return
const previousContent = viewportMeta.content
const normalized = previousContent
.replace(/\s*,?\s*maximum-scale\s*=\s*[^,]+/gi, '')
.replace(/\s*,?\s*user-scalable\s*=\s*[^,]+/gi, '')
.trim()
.replace(/,+$/g, '')
viewportMeta.content = `${normalized},maximum-scale=1,user-scalable=no`
return () => {
viewportMeta.content = previousContent
}
}, [detailsOpen, isMobileDialogLayout])
useEffect(() => { useEffect(() => {
const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track)) const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track))
if (candidates.length === 0) return if (candidates.length === 0) return
@@ -1647,7 +1705,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
</Alert> </Alert>
)} )}
<Dialog open={detailsOpen} onClose={closeDetails} fullWidth maxWidth="sm" fullScreen={isCompactTableLayout}> <Dialog
open={detailsOpen}
onClose={closeDetails}
fullWidth
maxWidth="sm"
fullScreen={isMobileDialogLayout}
PaperProps={{
sx: isMobileDialogLayout
? {
m: 0,
width: '100%',
maxWidth: '100%',
height: '100dvh',
maxHeight: '100dvh',
borderRadius: 0,
touchAction: 'manipulation',
}
: undefined,
}}
>
<DialogTitle>{detailsTrack ? getDisplayTitle(detailsTrack) : 'Item details'}</DialogTitle> <DialogTitle>{detailsTrack ? getDisplayTitle(detailsTrack) : 'Item details'}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
{detailsLoading && <Alert severity="info">Loading item details...</Alert>} {detailsLoading && <Alert severity="info">Loading item details...</Alert>}