update
This commit is contained in:
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user