diff --git a/public/userscripts/api-media-player-open-in-mpv.user.js b/public/userscripts/api-media-player-open-in-mpv.user.js index c604035..93b47f7 100644 --- a/public/userscripts/api-media-player-open-in-mpv.user.js +++ b/public/userscripts/api-media-player-open-in-mpv.user.js @@ -112,8 +112,11 @@ function buildDesktopMpvUrl(url, metadata) { const encodedUrl = encodeUrlSafeBase64(url) - const encodedTitle = metadata.title ? encodeUrlSafeBase64(metadata.title) : '' - const query = encodedTitle ? `?v_title=${encodedTitle}` : '' + 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}` } diff --git a/scripts/README.md b/scripts/README.md index 91dfffd..0117572 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -59,6 +59,16 @@ npm run setup:mpv-handler -- --skip-config 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. ## What the helper does diff --git a/scripts/__pycache__/linux-mpv-handler.cpython-314.pyc b/scripts/__pycache__/linux-mpv-handler.cpython-314.pyc new file mode 100644 index 0000000..b9d8903 Binary files /dev/null and b/scripts/__pycache__/linux-mpv-handler.cpython-314.pyc differ diff --git a/scripts/config.toml b/scripts/config.toml index 05c7190..3fd7a08 100644 --- a/scripts/config.toml +++ b/scripts/config.toml @@ -13,6 +13,11 @@ # Optional, Type: String # 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: # - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe" # - The path is an executable binary file, not a directory diff --git a/scripts/linux-mpv-handler.py b/scripts/linux-mpv-handler.py index 9fcdf22..a884606 100644 --- a/scripts/linux-mpv-handler.py +++ b/scripts/linux-mpv-handler.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 import base64 +import json import os import re +import socket import subprocess import sys from pathlib import Path @@ -33,15 +35,57 @@ def resolve_mpv_command(config_path: Path) -> list[str]: 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]: 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: command.append(f'--force-media-title={title}') command.append(target_url) 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) if parsed.scheme not in {'mpv-handler', 'mpv-handler-debug'}: raise ValueError(f'Unsupported scheme: {parsed.scheme}') @@ -57,7 +101,8 @@ def parse_protocol_url(raw_url: str) -> tuple[str, str]: title = '' if query.get('v_title'): 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: @@ -66,7 +111,7 @@ def main() -> int: return 1 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: print(f'Failed to parse mpv-handler URL: {error}', file=sys.stderr) return 1 @@ -74,6 +119,12 @@ def main() -> int: 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_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) try: diff --git a/src/App.tsx b/src/App.tsx index 4e39c62..e57282f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -200,7 +200,9 @@ function buildAndroidMpvIntentUrl(track: Track) { function buildDesktopMpvHandlerUrl(track: Track) { const metadata = buildExternalPlayerMetadata(track) 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}` } diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index cb68eb4..03e31a3 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -71,6 +71,24 @@ const STREAMABLE_FILE_EXTENSIONS = new Set([ '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 = { id: SortField label: string @@ -373,6 +391,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const [visibleCount, setVisibleCount] = useState(RESULTS_PAGE_SIZE) const theme = useTheme() const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm')) + const isMobileDialogLayout = useMediaQuery(theme.breakpoints.down('md')) const { servers, onlineServerIds, healthChecksComplete } = useServers() const hasServers = servers.length > 0 @@ -604,6 +623,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (tokens.length === 0) return true 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) if (!term) return true @@ -640,7 +664,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (clauses.length === 0) return true return clauses.every((clause) => { - const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN) + const namespaceMatch = !/\s/.test(clause) ? clause.match(SEARCH_NAMESPACE_PATTERN) : null if (namespaceMatch) { const namespace = namespaceMatch[1].toLowerCase() const rawValue = namespaceMatch[2] @@ -701,9 +725,22 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr } 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) - 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) { @@ -1087,6 +1124,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const handleDownloadTrack = () => { if (!detailsTrack?.url || detailsTrackDownloading) return + closeDetails() onDownloadTrack(detailsTrack, detailsData) } @@ -1184,6 +1222,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return visibleResults }, [shouldShowGroupedTracks, visibleTrackGroups, visibleResults]) + useEffect(() => { + if (!detailsOpen || !isMobileDialogLayout || typeof document === 'undefined') return + + const viewportMeta = document.querySelector('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(() => { const candidates = visibleRenderedTracks.filter((track) => needsVisibleMediaInfoBackfill(track)) if (candidates.length === 0) return @@ -1647,7 +1705,26 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr )} - + {detailsTrack ? getDisplayTitle(detailsTrack) : 'Item details'} {detailsLoading && Loading item details...}