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
- 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))
- 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))
- Make folder status visible with dot + text label ([c50a8f4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c50a8f4))
---
@@ -21,8 +14,8 @@
| 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 |
| [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 |
| [4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e) | ci: add manual build workflow for testing artifacts | 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 |
| [cad6e8a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/cad6e8a) | feat: redesign media browser UI | 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>
+5
View File
@@ -56,6 +56,11 @@ scripts:
timeout: 10
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)
# All callbacks are optional - if not defined, the action runs without callback
callbacks:
+4
View File
@@ -124,6 +124,10 @@ class Settings(BaseSettings):
default_factory=dict,
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_size: str = Field(
+19 -2
View File
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
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:
"""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.
Returns:
Dictionary of folder configurations.
Dictionary with folder configurations and management flag.
"""
folders = {}
for folder_id, config in settings.media_folders.items():
folder_path = Path(config.path)
folders[folder_id] = {
"id": folder_id,
"label": config.label,
"path": config.path,
"enabled": config.enabled,
"available": folder_path.is_dir(),
}
return folders
return {
"folders": folders,
"management_enabled": settings.media_folders_management,
}
@router.post("/folders/create")
@@ -112,6 +126,7 @@ async def create_folder(
Raises:
HTTPException: If folder already exists or validation fails.
"""
_require_folder_management()
try:
# Validate folder_id format (alphanumeric and underscore only)
if not request.folder_id.replace("_", "").isalnum():
@@ -169,6 +184,7 @@ async def update_folder(
Raises:
HTTPException: If folder doesn't exist or validation fails.
"""
_require_folder_management()
try:
# Validate path exists
path = Path(request.path)
@@ -217,6 +233,7 @@ async def delete_folder(
Raises:
HTTPException: If folder doesn't exist.
"""
_require_folder_management()
try:
config_manager.delete_media_folder(folder_id)
+2
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
from ..config import settings
router = APIRouter(prefix="/api", tags=["health"])
@@ -23,6 +24,7 @@ async def health_check(request: Request) -> dict[str, Any]:
"platform": platform.system(),
"version": __version__,
"auth_required": auth_enabled(),
"media_folders_management": settings.media_folders_management,
}
# Include cached update info if available
+297 -98
View File
@@ -192,17 +192,67 @@ h1 {
}
.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;
height: 8px;
border-radius: 50%;
background: var(--error);
flex-shrink: 0;
transition: background 0.3s;
}
.status-dot.connected {
.status-dot.connected::before,
.status-dot.status-online::before {
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 {
display: flex;
align-items: center;
@@ -2681,7 +2731,7 @@ footer .separator {
.browser-container {
background: var(--bg-secondary);
border-radius: 12px;
padding: 1rem;
padding: 1.25rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
@@ -2702,14 +2752,20 @@ footer .separator {
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.25rem;
margin-bottom: 1rem;
padding: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 0.875rem;
border-radius: 8px;
font-size: 0.813rem;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
border: 1px solid var(--border);
}
.breadcrumb::-webkit-scrollbar {
display: none;
}
.breadcrumb:empty {
@@ -2717,28 +2773,44 @@ footer .separator {
}
.breadcrumb-item {
color: var(--accent);
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
transition: all 0.2s;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.breadcrumb-item:hover {
color: var(--accent-hover);
text-decoration: underline;
color: var(--accent);
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 {
display: flex;
align-items: center;
padding: 0.25rem;
color: var(--text-muted);
}
.breadcrumb-home:hover {
text-decoration: none;
color: var(--accent);
}
.breadcrumb-separator {
color: var(--text-muted);
margin: 0 0.25rem;
margin: 0;
opacity: 0.5;
font-size: 0.75rem;
}
/* Browser Toolbar */
@@ -2909,13 +2981,19 @@ footer .separator {
/* Browser Grid */
.browser-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
min-height: 200px;
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 */
.browser-grid.browser-grid-compact {
grid-template-columns: repeat(auto-fill, minmax(80px, 100px));
@@ -2952,41 +3030,66 @@ footer .separator {
.browser-list {
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
margin-bottom: 1.5rem;
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 {
display: grid;
grid-template-columns: 40px 1fr auto auto auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
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 {
background: var(--bg-tertiary);
border-color: var(--border);
}
.browser-list-item:active {
background: var(--border);
border-color: var(--accent);
}
.browser-list-icon {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
border-radius: 4px;
background: var(--bg-primary);
border-radius: 6px;
background: var(--bg-tertiary);
flex-shrink: 0;
overflow: hidden;
position: relative;
@@ -2998,8 +3101,8 @@ footer .separator {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
@@ -3016,10 +3119,10 @@ footer .separator {
}
.browser-list-thumbnail {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 4px;
border-radius: 6px;
}
.browser-list-thumbnail.loading {
@@ -3046,6 +3149,7 @@ footer .separator {
white-space: nowrap;
min-width: 55px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.browser-list-duration {
@@ -3063,6 +3167,7 @@ footer .separator {
white-space: nowrap;
min-width: 60px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.browser-loading {
@@ -3087,85 +3192,114 @@ footer .separator {
.browser-item {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
border: 1px solid transparent;
border-radius: 10px;
padding: 0.6rem;
cursor: pointer;
transition: all 0.2s;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
position: relative;
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 {
from { opacity: 0; transform: translateY(8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.browser-item:hover {
background: var(--border);
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border-color: var(--border);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.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 */
.browser-thumbnail {
width: 90px;
height: 90px;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
border-radius: 8px;
background: var(--bg-primary);
display: block;
}
.browser-thumbnail.loading {
background: linear-gradient(
90deg,
var(--bg-primary) 25%,
110deg,
var(--bg-primary) 30%,
var(--bg-tertiary) 50%,
var(--bg-primary) 75%
var(--bg-primary) 70%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
position: relative;
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;
animation: shimmer 1.8s ease-in-out infinite;
opacity: 1;
}
.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; }
100% { background-position: -200% 0; }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
transform: scale(0.97);
}
to {
opacity: 1;
@@ -3175,13 +3309,13 @@ footer .separator {
/* File/Folder Icons */
.browser-icon {
width: 90px;
height: 90px;
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
border-radius: 6px;
font-size: 2.5rem;
border-radius: 8px;
background: var(--bg-primary);
}
@@ -3189,10 +3323,11 @@ footer .separator {
width: 100%;
text-align: center;
margin-top: auto;
padding: 0 0.15rem;
}
.browser-item-name {
font-size: 0.813rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
@@ -3201,12 +3336,14 @@ footer .separator {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.browser-item-meta {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
font-size: 0.688rem;
color: var(--text-muted);
margin-top: 0.2rem;
line-height: 1.3;
}
.browser-item-type {
@@ -3222,6 +3359,11 @@ footer .separator {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.browser-item:hover .browser-item-type {
opacity: 0.85;
}
@@ -3236,9 +3378,11 @@ footer .separator {
/* Thumbnail Wrapper & Play Overlay */
.browser-thumb-wrapper {
position: relative;
width: 90px;
height: 90px;
width: 100%;
aspect-ratio: 1;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.browser-thumb-wrapper .browser-thumbnail,
@@ -3253,24 +3397,29 @@ footer .separator {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
border-radius: 6px;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.browser-play-overlay svg {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
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 {
opacity: 1;
}
.browser-item:hover .browser-play-overlay svg {
transform: scale(1.1);
}
/* Compact grid overrides */
.browser-grid-compact .browser-thumb-wrapper {
width: 100%;
@@ -3287,7 +3436,7 @@ footer .separator {
background: transparent;
border: none;
border-radius: 4px;
padding: 0.2rem;
padding: 0.25rem;
color: var(--text-muted);
cursor: pointer;
transition: color 0.15s;
@@ -3297,6 +3446,11 @@ footer .separator {
justify-content: center;
width: auto;
height: auto;
opacity: 0;
}
.browser-list-item:hover .browser-list-download {
opacity: 1;
}
.browser-list-download:hover {
@@ -3308,7 +3462,7 @@ footer .separator {
/* Pagination */
.pagination {
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-top: 1rem;
@@ -3316,13 +3470,13 @@ footer .separator {
}
.pagination button {
padding: 0.5rem 1.5rem;
padding: 0.4rem 1.25rem;
border-radius: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
cursor: pointer;
font-size: 0.875rem;
font-size: 0.813rem;
font-weight: 600;
transition: all 0.2s;
width: auto;
@@ -3345,19 +3499,25 @@ footer .separator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-size: 0.813rem;
color: var(--text-secondary);
}
.pagination-showing {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.page-input {
width: 3.5rem;
padding: 0.3rem 0.4rem;
width: 3rem;
padding: 0.25rem 0.35rem;
text-align: center;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.875rem;
font-size: 0.813rem;
-moz-appearance: textfield;
}
@@ -3374,8 +3534,17 @@ footer .separator {
/* Responsive Design */
@media (max-width: 600px) {
.browser-container {
padding: 0.75rem;
}
.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;
}
@@ -3383,17 +3552,13 @@ footer .separator {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.browser-thumb-wrapper {
width: 100px;
height: 100px;
}
.browser-icon {
font-size: 2.5rem;
}
.browser-item {
padding: 0.75rem;
padding: 0.5rem;
}
.browser-item.browser-root-folder {
padding: 1rem 0.75rem;
min-height: 100px;
}
.browser-header-section {
@@ -3429,12 +3594,27 @@ footer .separator {
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 {
grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
}
.browser-list-icon {
width: 32px;
height: 32px;
}
.browser-list-duration {
display: none;
}
@@ -3447,6 +3627,17 @@ footer .separator {
display: none;
}
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-showing {
flex-basis: 100%;
text-align: center;
order: -1;
}
.album-art-glow {
width: 250px;
height: 250px;
@@ -3485,6 +3676,14 @@ footer .separator {
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 {
grid-template-columns: 40px 1fr auto auto auto;
}
+34
View File
@@ -290,6 +290,7 @@
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
<span class="pagination-showing" id="paginationShowing"></span>
</div>
</div>
@@ -323,6 +324,39 @@
</div>
</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>
<summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content">
+21 -1
View File
@@ -57,6 +57,7 @@ import {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
} from './browser.js';
import {
@@ -117,6 +118,7 @@ Object.assign(window, {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
// Links
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
saveLink, deleteLinkConfirm,
@@ -323,6 +325,24 @@ window.addEventListener('DOMContentLoaded', async () => {
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
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
@@ -352,7 +372,7 @@ window.addEventListener('DOMContentLoaded', async () => {
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
if (!authReq || token) {
loadMediaFolders();
}
+213 -24
View File
@@ -3,7 +3,7 @@
// ============================================================
import {
t, showToast, escapeHtml, closeDialog,
t, showToast, showConfirm, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials,
} from './core.js';
@@ -15,6 +15,7 @@ let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let managementEnabled = false;
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
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
loadLastBrowserPath();
@@ -69,41 +83,48 @@ function showRootFolders() {
revokeBlobUrls(container);
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
container.className = 'browser-grid browser-root-grid';
}
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]) => {
if (!folder.enabled) return;
const unavailable = folder.available === false;
const unavailableClass = unavailable ? ' unavailable' : '';
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.className = 'browser-list-item' + unavailableClass;
if (!unavailable) {
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div>
<div class="browser-list-name">${folder.label}</div>
<div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
<div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.className = 'browser-item browser-root-folder' + unavailableClass;
if (!unavailable) {
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div>
<div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
</div>
<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>
`;
container.appendChild(card);
@@ -248,6 +269,19 @@ function renderBrowserList(items, container) {
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) => {
const row = document.createElement('div');
row.className = 'browser-list-item';
@@ -662,6 +696,7 @@ function renderPagination() {
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const showingEl = document.getElementById('paginationShowing');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
@@ -676,6 +711,13 @@ function renderPagination() {
pageInput.max = 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;
nextBtn.disabled = currentPage === totalPages;
}
@@ -845,10 +887,72 @@ function loadLastBrowserPath() {
}
}
// Folder Management
export function showManageFoldersDialog() {
// TODO: Implement folder management UI
showToast(t('browser.manage_folders_hint'), 'info');
// Folder Management — Settings table
export function loadFoldersTable() {
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() {
@@ -857,5 +961,90 @@ export function closeFolderDialog() {
export async function saveFolder(event) {
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.error_loading": "Error loading directory",
"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_edit": "Edit Media Folder",
"browser.folder_dialog.folder_id": "Folder ID *",
@@ -185,6 +205,11 @@
"browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel",
"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",
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
"connection.lost": "Connection lost. Server may be unavailable.",
+26 -1
View File
@@ -173,7 +173,27 @@
"browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога",
"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_edit": "Редактировать медиа папку",
"browser.folder_dialog.folder_id": "ID папки *",
@@ -185,6 +205,11 @@
"browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена",
"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": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"devDependencies": {
"esbuild": "^0.27.4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"private": true,
"description": "Frontend build tooling for media server WebUI",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "0.1.1"
version = "0.1.2"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }