Compare commits

...

4 Commits

Author SHA1 Message Date
alexei.dolgolyov 2961f8eaec chore: release v0.1.2
Release / create-release (push) Successful in 4s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 29s
Release / build-windows (push) Successful in 1m11s
2026-03-29 20:00:38 +03:00
alexei.dolgolyov c50a8f472c fix: make folder status visible with dot + text label
Lint & Test / test (push) Successful in 10s
Status dot was 8x8px with no text, nearly invisible in the table.
Now renders as a colored dot with an adjacent text label
(Available / Unavailable).
2026-03-29 15:07:46 +03:00
alexei.dolgolyov cad6e8a1fe feat: redesign media browser UI
Lint & Test / test (push) Successful in 9s
- Root folder cards with hero-style layout and SVG icons
- Full-width thumbnails with aspect-ratio grid items
- List view column headers (Name, Bitrate, Duration, Size)
- Modernized breadcrumb with pill segments and overflow handling
- Proper skeleton shimmer replacing emoji hourglass on thumbnails
- Pagination shows "Showing X-Y of Z" item count
- Refined hover effects, animations, and visual hierarchy
- Download button revealed on row hover in list view
- Type badges hidden by default, shown on hover
- Localized new keys in en.json and ru.json
2026-03-29 14:59:43 +03:00
alexei.dolgolyov c9ee41ad35 feat: add media folder management from WebUI
Lint & Test / test (push) Successful in 10s
- Add media_folders_management config flag (enabled by default)
- Guard folder CRUD endpoints with 403 when management disabled
- Wire up frontend folder add/edit/delete in Settings tab
- Add per-folder availability check (for network shares)
- Show unavailable badge on offline folders in browser view
- Expose management flag via /api/health endpoint
- Add EN/RU locale keys for folder management UI
2026-03-29 14:44:03 +03:00
14 changed files with 660 additions and 147 deletions
+9 -16
View File
@@ -1,18 +1,11 @@
## v0.1.1 (2026-03-28) ## v0.1.2 (2026-03-29)
### Features
- Redesign media browser UI ([cad6e8a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/cad6e8a))
- Add media folder management from WebUI ([c9ee41a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c9ee41a))
### Bug Fixes ### Bug Fixes
- Use custom app icon for Windows shortcuts instead of the default Python executable icon ([5e5e503](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5e5e503)) - Make folder status visible with dot + text label ([c50a8f4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c50a8f4))
- Check if port is already in use before starting the server ([5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263))
### Improvements
- Replace `packaging` library with lightweight built-in version comparison — one fewer dependency ([5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263))
---
### Development / Internal
#### CI/Build
- Add manual build workflow for testing artifacts without tagging a release ([4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e))
--- ---
@@ -21,8 +14,8 @@
| Hash | Message | Author | | Hash | Message | Author |
|------|---------|--------| |------|---------|--------|
| [5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263) | fix: port-in-use check and remove packaging dependency | alexei.dolgolyov | | [c50a8f4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c50a8f4) | fix: make folder status visible with dot + text label | alexei.dolgolyov |
| [5e5e503](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5e5e503) | fix: use custom icon for Windows shortcuts instead of python.exe | alexei.dolgolyov | | [cad6e8a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/cad6e8a) | feat: redesign media browser UI | alexei.dolgolyov |
| [4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e) | ci: add manual build workflow for testing artifacts | alexei.dolgolyov | | [c9ee41a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c9ee41a) | feat: add media folder management from WebUI | alexei.dolgolyov |
</details> </details>
+5
View File
@@ -56,6 +56,11 @@ scripts:
timeout: 10 timeout: 10
shell: true shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI.
# media_folders_management: false
# Callback scripts (executed after media actions) # Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback # All callbacks are optional - if not defined, the action runs without callback
callbacks: callbacks:
+4
View File
@@ -124,6 +124,10 @@ class Settings(BaseSettings):
default_factory=dict, default_factory=dict,
description="Media folders available for browsing in the media browser", description="Media folders available for browsing in the media browser",
) )
media_folders_management: bool = Field(
default=True,
description="Allow adding, editing, and deleting media folders from the Web UI",
)
# Thumbnail settings # Thumbnail settings
thumbnail_size: str = Field( thumbnail_size: str = Field(
+19 -2
View File
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"]) router = APIRouter(prefix="/api/browser", tags=["browser"])
def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config."""
if not settings.media_folders_management:
raise HTTPException(
status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
)
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None: async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
"""Poll until media session registers, then broadcast status update. """Poll until media session registers, then broadcast status update.
@@ -83,17 +92,22 @@ async def list_folders(_: str = Depends(verify_token)):
"""List all configured media folders. """List all configured media folders.
Returns: Returns:
Dictionary of folder configurations. Dictionary with folder configurations and management flag.
""" """
folders = {} folders = {}
for folder_id, config in settings.media_folders.items(): for folder_id, config in settings.media_folders.items():
folder_path = Path(config.path)
folders[folder_id] = { folders[folder_id] = {
"id": folder_id, "id": folder_id,
"label": config.label, "label": config.label,
"path": config.path, "path": config.path,
"enabled": config.enabled, "enabled": config.enabled,
"available": folder_path.is_dir(),
}
return {
"folders": folders,
"management_enabled": settings.media_folders_management,
} }
return folders
@router.post("/folders/create") @router.post("/folders/create")
@@ -112,6 +126,7 @@ async def create_folder(
Raises: Raises:
HTTPException: If folder already exists or validation fails. HTTPException: If folder already exists or validation fails.
""" """
_require_folder_management()
try: try:
# Validate folder_id format (alphanumeric and underscore only) # Validate folder_id format (alphanumeric and underscore only)
if not request.folder_id.replace("_", "").isalnum(): if not request.folder_id.replace("_", "").isalnum():
@@ -169,6 +184,7 @@ async def update_folder(
Raises: Raises:
HTTPException: If folder doesn't exist or validation fails. HTTPException: If folder doesn't exist or validation fails.
""" """
_require_folder_management()
try: try:
# Validate path exists # Validate path exists
path = Path(request.path) path = Path(request.path)
@@ -217,6 +233,7 @@ async def delete_folder(
Raises: Raises:
HTTPException: If folder doesn't exist. HTTPException: If folder doesn't exist.
""" """
_require_folder_management()
try: try:
config_manager.delete_media_folder(folder_id) config_manager.delete_media_folder(folder_id)
+2
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Request
from .. import __version__ from .. import __version__
from ..auth import auth_enabled from ..auth import auth_enabled
from ..config import settings
router = APIRouter(prefix="/api", tags=["health"]) router = APIRouter(prefix="/api", tags=["health"])
@@ -23,6 +24,7 @@ async def health_check(request: Request) -> dict[str, Any]:
"platform": platform.system(), "platform": platform.system(),
"version": __version__, "version": __version__,
"auth_required": auth_enabled(), "auth_required": auth_enabled(),
"media_folders_management": settings.media_folders_management,
} }
# Include cached update info if available # Include cached update info if available
+297 -98
View File
@@ -192,17 +192,67 @@ h1 {
} }
.status-dot { .status-dot {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: var(--text-muted);
transition: color 0.3s;
}
.status-dot::before {
content: '';
display: inline-block;
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--error); background: var(--error);
flex-shrink: 0;
transition: background 0.3s; transition: background 0.3s;
} }
.status-dot.connected { .status-dot.connected::before,
.status-dot.status-online::before {
background: var(--accent); background: var(--accent);
} }
.status-dot.status-offline::before {
background: var(--error);
}
/* Folder management */
.folder-unavailable-badge,
.folder-disabled-badge {
font-size: 0.75rem;
padding: 1px 6px;
border-radius: 4px;
vertical-align: middle;
margin-left: 4px;
}
.folder-unavailable-badge {
background: color-mix(in srgb, var(--error) 20%, transparent);
color: var(--error);
}
.folder-disabled-badge {
background: color-mix(in srgb, var(--text-secondary) 20%, transparent);
color: var(--text-secondary);
}
.browser-item.unavailable,
.browser-list-item.unavailable {
opacity: 0.5;
cursor: default;
}
.path-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-toolbar { .header-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2681,7 +2731,7 @@ footer .separator {
.browser-container { .browser-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 1rem; padding: 1.25rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -2702,14 +2752,20 @@ footer .separator {
.breadcrumb { .breadcrumb {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.25rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.5rem 0.75rem;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 6px; border-radius: 8px;
font-size: 0.875rem; font-size: 0.813rem;
overflow-x: auto; overflow-x: auto;
white-space: nowrap; white-space: nowrap;
scrollbar-width: none;
border: 1px solid var(--border);
}
.breadcrumb::-webkit-scrollbar {
display: none;
} }
.breadcrumb:empty { .breadcrumb:empty {
@@ -2717,28 +2773,44 @@ footer .separator {
} }
.breadcrumb-item { .breadcrumb-item {
color: var(--accent); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: color 0.2s; transition: all 0.2s;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-weight: 500;
} }
.breadcrumb-item:hover { .breadcrumb-item:hover {
color: var(--accent-hover); color: var(--accent);
text-decoration: underline; background: rgba(29, 185, 84, 0.08);
text-decoration: none;
}
.breadcrumb-item:last-child {
color: var(--text-primary);
font-weight: 600;
cursor: default;
pointer-events: none;
} }
.breadcrumb-home { .breadcrumb-home {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.25rem;
color: var(--text-muted);
} }
.breadcrumb-home:hover { .breadcrumb-home:hover {
text-decoration: none; text-decoration: none;
color: var(--accent);
} }
.breadcrumb-separator { .breadcrumb-separator {
color: var(--text-muted); color: var(--text-muted);
margin: 0 0.25rem; margin: 0;
opacity: 0.5;
font-size: 0.75rem;
} }
/* Browser Toolbar */ /* Browser Toolbar */
@@ -2909,13 +2981,19 @@ footer .separator {
/* Browser Grid */ /* Browser Grid */
.browser-grid { .browser-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 200px; min-height: 200px;
align-items: stretch; align-items: stretch;
} }
/* Root folder grid — wider cards */
.browser-grid.browser-root-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
/* Compact Grid */ /* Compact Grid */
.browser-grid.browser-grid-compact { .browser-grid.browser-grid-compact {
grid-template-columns: repeat(auto-fill, minmax(80px, 100px)); grid-template-columns: repeat(auto-fill, minmax(80px, 100px));
@@ -2952,41 +3030,66 @@ footer .separator {
.browser-list { .browser-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 200px; min-height: 200px;
} }
/* List view column header */
.browser-list-header {
display: grid;
grid-template-columns: 40px 1fr auto auto auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.75rem;
font-size: 0.688rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
user-select: none;
}
.browser-list-header span:nth-child(n+3) {
text-align: right;
}
.browser-list-item { .browser-list-item {
display: grid; display: grid;
grid-template-columns: 40px 1fr auto auto auto auto; grid-template-columns: 40px 1fr auto auto auto auto;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: var(--bg-tertiary); background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 4px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
animation: itemFadeIn 0.3s ease-out backwards; animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--item-index, 0) * 20ms); animation-delay: calc(var(--item-index, 0) * 15ms);
} }
.browser-list-item:hover { .browser-list-item:hover {
background: var(--bg-tertiary);
border-color: var(--border);
}
.browser-list-item:active {
background: var(--border); background: var(--border);
border-color: var(--accent);
} }
.browser-list-icon { .browser-list-icon {
width: 32px; width: 36px;
height: 32px; height: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.25rem; font-size: 1.25rem;
border-radius: 4px; border-radius: 6px;
background: var(--bg-primary); background: var(--bg-tertiary);
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@@ -2998,8 +3101,8 @@ footer .separator {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.55);
border-radius: 4px; border-radius: 6px;
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
pointer-events: none; pointer-events: none;
@@ -3016,10 +3119,10 @@ footer .separator {
} }
.browser-list-thumbnail { .browser-list-thumbnail {
width: 32px; width: 36px;
height: 32px; height: 36px;
object-fit: cover; object-fit: cover;
border-radius: 4px; border-radius: 6px;
} }
.browser-list-thumbnail.loading { .browser-list-thumbnail.loading {
@@ -3046,6 +3149,7 @@ footer .separator {
white-space: nowrap; white-space: nowrap;
min-width: 55px; min-width: 55px;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums;
} }
.browser-list-duration { .browser-list-duration {
@@ -3063,6 +3167,7 @@ footer .separator {
white-space: nowrap; white-space: nowrap;
min-width: 60px; min-width: 60px;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums;
} }
.browser-loading { .browser-loading {
@@ -3087,85 +3192,114 @@ footer .separator {
.browser-item { .browser-item {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border); border: 1px solid transparent;
border-radius: 8px; border-radius: 10px;
padding: 0.6rem; padding: 0.6rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
position: relative; position: relative;
animation: itemFadeIn 0.3s ease-out backwards; animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--item-index, 0) * 30ms); animation-delay: calc(var(--item-index, 0) * 25ms);
} }
@keyframes itemFadeIn { @keyframes itemFadeIn {
from { opacity: 0; transform: translateY(8px) scale(0.97); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0); }
} }
.browser-item:hover { .browser-item:hover {
background: var(--border); border-color: var(--border);
border-color: var(--accent); transform: translateY(-3px);
transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.browser-item:active {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Root Folder Cards — distinctive hero style */
.browser-item.browser-root-folder {
padding: 1.25rem 1rem;
gap: 0.75rem;
border: 1px solid var(--border);
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
min-height: 120px;
justify-content: center;
}
.browser-item.browser-root-folder .browser-thumb-wrapper {
width: auto;
height: auto;
}
.browser-item.browser-root-folder .browser-icon {
width: 56px;
height: 56px;
font-size: 1.75rem;
border-radius: 14px;
background: rgba(29, 185, 84, 0.1);
border: 1px solid rgba(29, 185, 84, 0.15);
transition: all 0.25s;
}
.browser-item.browser-root-folder:hover .browser-icon {
background: rgba(29, 185, 84, 0.18);
border-color: rgba(29, 185, 84, 0.3);
transform: scale(1.05);
}
.browser-item.browser-root-folder .browser-item-name {
font-size: 0.875rem;
font-weight: 600;
}
/* Unavailable root folder overlay */
.browser-item.browser-root-folder.unavailable .browser-icon {
background: rgba(231, 76, 60, 0.08);
border-color: rgba(231, 76, 60, 0.12);
opacity: 0.6;
}
/* Thumbnail Display */ /* Thumbnail Display */
.browser-thumbnail { .browser-thumbnail {
width: 90px; width: 100%;
height: 90px; aspect-ratio: 1;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 8px;
background: var(--bg-primary); background: var(--bg-primary);
display: block; display: block;
} }
.browser-thumbnail.loading { .browser-thumbnail.loading {
background: linear-gradient( background: linear-gradient(
90deg, 110deg,
var(--bg-primary) 25%, var(--bg-primary) 30%,
var(--bg-tertiary) 50%, var(--bg-tertiary) 50%,
var(--bg-primary) 75% var(--bg-primary) 70%
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: loading 1.5s infinite; animation: shimmer 1.8s ease-in-out infinite;
position: relative; opacity: 1;
opacity: 0;
}
.browser-thumbnail.loading::after {
content: '⏳';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
opacity: 0.6;
animation: pulse 1.5s infinite;
} }
.browser-thumbnail.loaded { .browser-thumbnail.loaded {
animation: fadeIn 0.5s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;
} }
@keyframes loading { @keyframes shimmer {
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.97);
} }
to { to {
opacity: 1; opacity: 1;
@@ -3175,13 +3309,13 @@ footer .separator {
/* File/Folder Icons */ /* File/Folder Icons */
.browser-icon { .browser-icon {
width: 90px; width: 100%;
height: 90px; aspect-ratio: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 3rem; font-size: 2.5rem;
border-radius: 6px; border-radius: 8px;
background: var(--bg-primary); background: var(--bg-primary);
} }
@@ -3189,10 +3323,11 @@ footer .separator {
width: 100%; width: 100%;
text-align: center; text-align: center;
margin-top: auto; margin-top: auto;
padding: 0 0.15rem;
} }
.browser-item-name { .browser-item-name {
font-size: 0.813rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: var(--text-primary); color: var(--text-primary);
word-break: break-word; word-break: break-word;
@@ -3201,12 +3336,14 @@ footer .separator {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
line-height: 1.3;
} }
.browser-item-meta { .browser-item-meta {
font-size: 0.75rem; font-size: 0.688rem;
color: var(--text-secondary); color: var(--text-muted);
margin-top: 0.25rem; margin-top: 0.2rem;
line-height: 1.3;
} }
.browser-item-type { .browser-item-type {
@@ -3222,6 +3359,11 @@ footer .separator {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.browser-item:hover .browser-item-type {
opacity: 0.85; opacity: 0.85;
} }
@@ -3236,9 +3378,11 @@ footer .separator {
/* Thumbnail Wrapper & Play Overlay */ /* Thumbnail Wrapper & Play Overlay */
.browser-thumb-wrapper { .browser-thumb-wrapper {
position: relative; position: relative;
width: 90px; width: 100%;
height: 90px; aspect-ratio: 1;
flex-shrink: 0; flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
} }
.browser-thumb-wrapper .browser-thumbnail, .browser-thumb-wrapper .browser-thumbnail,
@@ -3253,24 +3397,29 @@ footer .separator {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.5);
border-radius: 6px; border-radius: 8px;
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
pointer-events: none; pointer-events: none;
} }
.browser-play-overlay svg { .browser-play-overlay svg {
width: 40px; width: 36px;
height: 40px; height: 36px;
color: #fff; color: #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)); filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
transition: transform 0.15s;
} }
.browser-item:hover .browser-play-overlay { .browser-item:hover .browser-play-overlay {
opacity: 1; opacity: 1;
} }
.browser-item:hover .browser-play-overlay svg {
transform: scale(1.1);
}
/* Compact grid overrides */ /* Compact grid overrides */
.browser-grid-compact .browser-thumb-wrapper { .browser-grid-compact .browser-thumb-wrapper {
width: 100%; width: 100%;
@@ -3287,7 +3436,7 @@ footer .separator {
background: transparent; background: transparent;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 0.2rem; padding: 0.25rem;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s;
@@ -3297,6 +3446,11 @@ footer .separator {
justify-content: center; justify-content: center;
width: auto; width: auto;
height: auto; height: auto;
opacity: 0;
}
.browser-list-item:hover .browser-list-download {
opacity: 1;
} }
.browser-list-download:hover { .browser-list-download:hover {
@@ -3308,7 +3462,7 @@ footer .separator {
/* Pagination */ /* Pagination */
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding-top: 1rem; padding-top: 1rem;
@@ -3316,13 +3470,13 @@ footer .separator {
} }
.pagination button { .pagination button {
padding: 0.5rem 1.5rem; padding: 0.4rem 1.25rem;
border-radius: 6px; border-radius: 6px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.813rem;
font-weight: 600; font-weight: 600;
transition: all 0.2s; transition: all 0.2s;
width: auto; width: auto;
@@ -3345,19 +3499,25 @@ footer .separator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.875rem; font-size: 0.813rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.pagination-showing {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.page-input { .page-input {
width: 3.5rem; width: 3rem;
padding: 0.3rem 0.4rem; padding: 0.25rem 0.35rem;
text-align: center; text-align: center;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
color: var(--text-primary); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.813rem;
-moz-appearance: textfield; -moz-appearance: textfield;
} }
@@ -3374,8 +3534,17 @@ footer .separator {
/* Responsive Design */ /* Responsive Design */
@media (max-width: 600px) { @media (max-width: 600px) {
.browser-container {
padding: 0.75rem;
}
.browser-grid { .browser-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
}
.browser-grid.browser-root-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
@@ -3383,17 +3552,13 @@ footer .separator {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
} }
.browser-thumb-wrapper {
width: 100px;
height: 100px;
}
.browser-icon {
font-size: 2.5rem;
}
.browser-item { .browser-item {
padding: 0.75rem; padding: 0.5rem;
}
.browser-item.browser-root-folder {
padding: 1rem 0.75rem;
min-height: 100px;
} }
.browser-header-section { .browser-header-section {
@@ -3429,12 +3594,27 @@ footer .separator {
display: none; display: none;
} }
.browser-list-header {
grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
}
.browser-list-header span:nth-child(n+3):nth-child(-n+4) {
display: none;
}
.browser-list-item { .browser-list-item {
grid-template-columns: 32px 1fr auto auto; grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem; gap: 0.5rem;
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
} }
.browser-list-icon {
width: 32px;
height: 32px;
}
.browser-list-duration { .browser-list-duration {
display: none; display: none;
} }
@@ -3447,6 +3627,17 @@ footer .separator {
display: none; display: none;
} }
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-showing {
flex-basis: 100%;
text-align: center;
order: -1;
}
.album-art-glow { .album-art-glow {
width: 250px; width: 250px;
height: 250px; height: 250px;
@@ -3485,6 +3676,14 @@ footer .separator {
display: none; display: none;
} }
.browser-list-header {
grid-template-columns: 40px 1fr auto auto auto;
}
.browser-list-header span:nth-child(3) {
display: none;
}
.browser-list-item { .browser-list-item {
grid-template-columns: 40px 1fr auto auto auto; grid-template-columns: 40px 1fr auto auto auto;
} }
+34
View File
@@ -290,6 +290,7 @@
<span id="pageTotal">/ 1</span> <span id="pageTotal">/ 1</span>
</div> </div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button> <button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
<span class="pagination-showing" id="paginationShowing"></span>
</div> </div>
</div> </div>
@@ -323,6 +324,39 @@
</div> </div>
</details> </details>
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="browser.folders_description">
Media folders available for browsing. Folders on network shares show availability status.
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="browser.folders_table.id">ID</th>
<th data-i18n="browser.folders_table.label">Label</th>
<th data-i18n="browser.folders_table.path">Path</th>
<th data-i18n="browser.folders_table.status">Status</th>
<th data-i18n="browser.folders_table.actions">Actions</th>
</tr>
</thead>
<tbody id="foldersTableBody">
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddFolderDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
<details class="settings-section" open> <details class="settings-section" open>
<summary data-i18n="settings.section.scripts">Scripts</summary> <summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content"> <div class="settings-section-content">
+21 -1
View File
@@ -57,6 +57,7 @@ import {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder, downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog, showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
} from './browser.js'; } from './browser.js';
import { import {
@@ -117,6 +118,7 @@ Object.assign(window, {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder, downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog, showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
// Links // Links
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
saveLink, deleteLinkConfirm, saveLink, deleteLinkConfirm,
@@ -323,6 +325,24 @@ window.addEventListener('DOMContentLoaded', async () => {
else if (action === 'delete') deleteCallbackConfirm(name); else if (action === 'delete') deleteCallbackConfirm(name);
}); });
// Folder dialog backdrop click to close
const folderDialog = document.getElementById('folderDialog');
folderDialog.addEventListener('click', (e) => {
if (e.target === folderDialog) {
closeFolderDialog();
}
});
// Delegated click handlers for folder table actions
document.getElementById('foldersTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const folderId = btn.dataset.folderId;
if (action === 'edit') showEditFolderDialog(folderId);
else if (action === 'delete') deleteFolderConfirm(folderId);
});
// Link dialog backdrop click to close // Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog'); const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => { linkDialog.addEventListener('click', (e) => {
@@ -352,7 +372,7 @@ window.addEventListener('DOMContentLoaded', async () => {
// Initialize browser toolbar and load folders // Initialize browser toolbar and load folders
initBrowserToolbar(); initBrowserToolbar();
if (token) { if (!authReq || token) {
loadMediaFolders(); loadMediaFolders();
} }
+205 -16
View File
@@ -3,7 +3,7 @@
// ============================================================ // ============================================================
import { import {
t, showToast, escapeHtml, closeDialog, t, showToast, showConfirm, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml, SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials, getAuthHeaders, hasCredentials,
} from './core.js'; } from './core.js';
@@ -15,6 +15,7 @@ let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100; let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0; let totalItems = 0;
let mediaFolders = {}; let mediaFolders = {};
let managementEnabled = false;
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null; let cachedItems = null;
let browserSearchTerm = ''; let browserSearchTerm = '';
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
if (!response.ok) throw new Error('Failed to load folders'); if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json(); const data = await response.json();
mediaFolders = data.folders || {};
managementEnabled = data.management_enabled || false;
// Show/hide the media folders settings section
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.style.display = managementEnabled ? '' : 'none';
}
// Render folders table in settings if management is enabled
if (managementEnabled) {
loadFoldersTable();
}
// Load last browsed path or show root folder list // Load last browsed path or show root folder list
loadLastBrowserPath(); loadLastBrowserPath();
@@ -69,41 +83,48 @@ function showRootFolders() {
revokeBlobUrls(container); revokeBlobUrls(container);
if (viewMode === 'list') { if (viewMode === 'list') {
container.className = 'browser-list'; container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else { } else {
container.className = 'browser-grid'; container.className = 'browser-grid browser-root-grid';
} }
container.innerHTML = ''; container.innerHTML = '';
const folderSvg = '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
Object.entries(mediaFolders).forEach(([id, folder]) => { Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return; if (!folder.enabled) return;
const unavailable = folder.available === false;
const unavailableClass = unavailable ? ' unavailable' : '';
if (viewMode === 'list') { if (viewMode === 'list') {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'browser-list-item'; row.className = 'browser-list-item' + unavailableClass;
if (!unavailable) {
row.onclick = () => { row.onclick = () => {
currentFolderId = id; currentFolderId = id;
browsePath(id, ''); browsePath(id, '');
}; };
}
row.innerHTML = ` row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div> <div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
<div class="browser-list-name">${folder.label}</div> <div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
`; `;
container.appendChild(row); container.appendChild(row);
} else { } else {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'browser-item'; card.className = 'browser-item browser-root-folder' + unavailableClass;
if (!unavailable) {
card.onclick = () => { card.onclick = () => {
currentFolderId = id; currentFolderId = id;
browsePath(id, ''); browsePath(id, '');
}; };
}
card.innerHTML = ` card.innerHTML = `
<div class="browser-thumb-wrapper"> <div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div> <div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
</div> </div>
<div class="browser-item-info"> <div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div> <div class="browser-item-name">${escapeHtml(folder.label)}</div>
${unavailable ? '<div class="browser-item-meta folder-unavailable-badge">' + t('browser.unavailable') + '</div>' : ''}
</div> </div>
`; `;
container.appendChild(card); container.appendChild(card);
@@ -248,6 +269,19 @@ function renderBrowserList(items, container) {
return; return;
} }
// Column header row
const header = document.createElement('div');
header.className = 'browser-list-header';
header.innerHTML = `
<span></span>
<span>${t('browser.list_header.name')}</span>
<span>${t('browser.list_header.bitrate')}</span>
<span>${t('browser.list_header.duration')}</span>
<span>${t('browser.list_header.size')}</span>
<span></span>
`;
container.appendChild(header);
items.forEach((item, idx) => { items.forEach((item, idx) => {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'browser-list-item'; row.className = 'browser-list-item';
@@ -662,6 +696,7 @@ function renderPagination() {
const nextBtn = document.getElementById('nextPage'); const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput'); const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal'); const pageTotal = document.getElementById('pageTotal');
const showingEl = document.getElementById('paginationShowing');
const totalPages = Math.ceil(totalItems / itemsPerPage); const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1; const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
@@ -676,6 +711,13 @@ function renderPagination() {
pageInput.max = totalPages; pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`; pageTotal.textContent = `/ ${totalPages}`;
// "Showing X-Y of Z"
if (showingEl) {
const from = currentOffset + 1;
const to = Math.min(currentOffset + itemsPerPage, totalItems);
showingEl.textContent = t('browser.showing_items', { from, to, total: totalItems });
}
prevBtn.disabled = currentPage === 1; prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages; nextBtn.disabled = currentPage === totalPages;
} }
@@ -845,10 +887,72 @@ function loadLastBrowserPath() {
} }
} }
// Folder Management // Folder Management — Settings table
export function showManageFoldersDialog() {
// TODO: Implement folder management UI export function loadFoldersTable() {
showToast(t('browser.manage_folders_hint'), 'info'); const tbody = document.getElementById('foldersTableBody');
if (!tbody) return;
const entries = Object.entries(mediaFolders);
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">${t('browser.folders_empty')}</p>
</div></td></tr>`;
return;
}
tbody.innerHTML = entries.map(([id, folder]) => {
const available = folder.available !== false;
const statusIcon = available
? '<span class="status-dot status-online">' + t('browser.folder_available') + '</span>'
: '<span class="status-dot status-offline">' + t('browser.folder_unavailable') + '</span>';
const enabledBadge = folder.enabled
? ''
: ' <span class="folder-disabled-badge">' + t('browser.folder_disabled') + '</span>';
return `<tr>
<td>${escapeHtml(id)}${enabledBadge}</td>
<td>${escapeHtml(folder.label)}</td>
<td class="path-cell" title="${escapeHtml(folder.path)}">${escapeHtml(folder.path)}</td>
<td>${statusIcon}</td>
<td class="actions-cell">
<button class="btn-icon" data-action="edit" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_edit')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="btn-icon btn-danger-icon" data-action="delete" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_delete')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</td>
</tr>`;
}).join('');
}
export function showAddFolderDialog() {
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add');
document.getElementById('folderIsEdit').value = '';
document.getElementById('folderOriginalId').value = '';
document.getElementById('folderId').value = '';
document.getElementById('folderId').disabled = false;
document.getElementById('folderLabel').value = '';
document.getElementById('folderPath').value = '';
document.getElementById('folderEnabled').checked = true;
document.getElementById('folderDialog').showModal();
}
export function showEditFolderDialog(folderId) {
const folder = mediaFolders[folderId];
if (!folder) return;
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit');
document.getElementById('folderIsEdit').value = '1';
document.getElementById('folderOriginalId').value = folderId;
document.getElementById('folderId').value = folderId;
document.getElementById('folderId').disabled = true;
document.getElementById('folderLabel').value = folder.label;
document.getElementById('folderPath').value = folder.path;
document.getElementById('folderEnabled').checked = folder.enabled;
document.getElementById('folderDialog').showModal();
} }
export function closeFolderDialog() { export function closeFolderDialog() {
@@ -857,5 +961,90 @@ export function closeFolderDialog() {
export async function saveFolder(event) { export async function saveFolder(event) {
event.preventDefault(); event.preventDefault();
closeFolderDialog();
const isEdit = document.getElementById('folderIsEdit').value === '1';
const folderId = isEdit
? document.getElementById('folderOriginalId').value
: document.getElementById('folderId').value.trim();
const label = document.getElementById('folderLabel').value.trim();
const path = document.getElementById('folderPath').value.trim();
const enabled = document.getElementById('folderEnabled').checked;
if (!folderId || !label || !path) return;
const submitBtn = document.querySelector('#folderForm button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
let response;
if (isEdit) {
response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ label, path, enabled }),
});
} else {
response = await fetch('/api/browser/folders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ folder_id: folderId, label, path, enabled }),
});
}
if (response.ok) {
closeFolderDialog();
showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_save_error'), 'error');
}
} catch (error) {
console.error('Error saving folder:', error);
showToast(t('browser.folder_save_error'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteFolderConfirm(folderId) {
if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) {
return;
}
try {
const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (response.ok) {
showToast(t('browser.folder_deleted'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_delete_error'), 'error');
}
} catch (error) {
console.error('Error deleting folder:', error);
showToast(t('browser.folder_delete_error'), 'error');
}
}
// Legacy stub — now handled via settings table
export function showManageFoldersDialog() {
if (managementEnabled) {
// Switch to settings tab and scroll to the folders section
const switchTabFn = window.switchTab;
if (switchTabFn) switchTabFn('settings');
setTimeout(() => {
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.setAttribute('open', '');
section.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
showToast(t('browser.manage_folders_hint'), 'info');
}
} }
+26 -1
View File
@@ -173,7 +173,27 @@
"browser.play_all_error": "Failed to play folder", "browser.play_all_error": "Failed to play folder",
"browser.error_loading": "Error loading directory", "browser.error_loading": "Error loading directory",
"browser.error_loading_folders": "Failed to load media folders", "browser.error_loading_folders": "Failed to load media folders",
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.", "browser.manage_folders_hint": "Folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
"browser.unavailable": "Unavailable",
"browser.folder_available": "Available",
"browser.folder_unavailable": "Unavailable (path not reachable)",
"browser.folder_disabled": "disabled",
"browser.folder_edit": "Edit folder",
"browser.folder_delete": "Delete folder",
"browser.folder_created": "Media folder created successfully",
"browser.folder_updated": "Media folder updated successfully",
"browser.folder_deleted": "Media folder deleted successfully",
"browser.folder_save_error": "Failed to save media folder",
"browser.folder_delete_error": "Failed to delete media folder",
"browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?",
"browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.",
"browser.folders_empty": "No media folders configured. Click \"+\" to add one.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Label",
"browser.folders_table.path": "Path",
"browser.folders_table.status": "Status",
"browser.folders_table.actions": "Actions",
"settings.section.media_folders": "Media Folders",
"browser.folder_dialog.title_add": "Add Media Folder", "browser.folder_dialog.title_add": "Add Media Folder",
"browser.folder_dialog.title_edit": "Edit Media Folder", "browser.folder_dialog.title_edit": "Edit Media Folder",
"browser.folder_dialog.folder_id": "Folder ID *", "browser.folder_dialog.folder_id": "Folder ID *",
@@ -185,6 +205,11 @@
"browser.folder_dialog.enabled": "Enabled", "browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel", "browser.folder_dialog.cancel": "Cancel",
"browser.folder_dialog.save": "Save", "browser.folder_dialog.save": "Save",
"browser.list_header.name": "Name",
"browser.list_header.bitrate": "Bitrate",
"browser.list_header.duration": "Duration",
"browser.list_header.size": "Size",
"browser.showing_items": "Showing {from}\u2013{to} of {total}",
"browser.download_error": "Failed to download file", "browser.download_error": "Failed to download file",
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...", "connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
"connection.lost": "Connection lost. Server may be unavailable.", "connection.lost": "Connection lost. Server may be unavailable.",
+26 -1
View File
@@ -173,7 +173,27 @@
"browser.play_all_error": "Не удалось воспроизвести папку", "browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога", "browser.error_loading": "Ошибка загрузки каталога",
"browser.error_loading_folders": "Не удалось загрузить медиа папки", "browser.error_loading_folders": "Не удалось загрузить медиа папки",
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.", "browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.",
"browser.unavailable": "Недоступна",
"browser.folder_available": "Доступна",
"browser.folder_unavailable": "Недоступна (путь не найден)",
"browser.folder_disabled": "отключена",
"browser.folder_edit": "Редактировать папку",
"browser.folder_delete": "Удалить папку",
"browser.folder_created": "Медиа папка успешно создана",
"browser.folder_updated": "Медиа папка успешно обновлена",
"browser.folder_deleted": "Медиа папка успешно удалена",
"browser.folder_save_error": "Не удалось сохранить медиа папку",
"browser.folder_delete_error": "Не удалось удалить медиа папку",
"browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?",
"browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.",
"browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Метка",
"browser.folders_table.path": "Путь",
"browser.folders_table.status": "Статус",
"browser.folders_table.actions": "Действия",
"settings.section.media_folders": "Медиа папки",
"browser.folder_dialog.title_add": "Добавить медиа папку", "browser.folder_dialog.title_add": "Добавить медиа папку",
"browser.folder_dialog.title_edit": "Редактировать медиа папку", "browser.folder_dialog.title_edit": "Редактировать медиа папку",
"browser.folder_dialog.folder_id": "ID папки *", "browser.folder_dialog.folder_id": "ID папки *",
@@ -185,6 +205,11 @@
"browser.folder_dialog.enabled": "Включено", "browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена", "browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить", "browser.folder_dialog.save": "Сохранить",
"browser.list_header.name": "Название",
"browser.list_header.bitrate": "Битрейт",
"browser.list_header.duration": "Длительность",
"browser.list_header.size": "Размер",
"browser.showing_items": "Показано {from}\u2013{to} из {total}",
"browser.download_error": "Не удалось скачать файл", "browser.download_error": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...", "connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.", "connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "1.0.0", "version": "0.1.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "1.0.0", "version": "0.1.2",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "1.0.0", "version": "0.1.2",
"private": true, "private": true,
"description": "Frontend build tooling for media server WebUI", "description": "Frontend build tooling for media server WebUI",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "media-server" name = "media-server"
version = "0.1.1" version = "0.1.2"
description = "REST API server for controlling system-wide media playback" description = "REST API server for controlling system-wide media playback"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }