Smithflix web app: theme, branding, AI rows, Not on Smithflix yet, Requests page, CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdkZRTCV92L7gbQX2Z5Ay
This commit is contained in:
Claude 2026-10-11 12:32:48 -04:00
parent fae41f33eb
commit 8f196145dc
No known key found for this signature in database
37 changed files with 695 additions and 573 deletions

View file

@ -1,18 +0,0 @@
name: Automation 🎛️
on:
workflow_call:
secrets:
JF_BOT_TOKEN:
required: true
jobs:
conflicts:
name: Merge conflict labeling 🏷️
runs-on: ubuntu-latest
steps:
- uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View file

@ -1,40 +0,0 @@
name: GitHub CodeQL 🔬
on:
workflow_call:
inputs:
commit:
required: true
type: string
jobs:
analyze:
name: Analyze ${{ matrix.language }} 🔬
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language:
- javascript-typescript
steps:
- name: Checkout repository ⬇️
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Initialize CodeQL 🛠️
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
queries: security-and-quality
languages: ${{ matrix.language }}
- name: Autobuild 📦
uses: github/codeql-action/autobuild@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
- name: Perform CodeQL Analysis 🧪
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: '/language:${{matrix.language}}'

View file

@ -1,86 +0,0 @@
name: Deploy 🏗️
on:
workflow_call:
secrets:
JF_BOT_TOKEN:
required: true
CLOUDFLARE_ACCOUNT_ID:
required: true
CLOUDFLARE_API_TOKEN:
required: true
inputs:
branch:
required: true
type: string
commit:
required: false
type: string
comment:
required: false
type: boolean
pr_number:
required: false
type: string
workflow_run_id:
required: true
type: string
artifact_name:
required: false
type: string
default: frontend
jobs:
progress-comment:
name: Progress Comment
if: ${{ always() && inputs.comment }}
uses: ./.github/workflows/__job_messages.yml
secrets:
JF_BOT_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
with:
branch: ${{ inputs.branch }}
commit: ${{ inputs.commit }}
pr_number: ${{ inputs.pr_number }}
comment: true
in_progress: true
cf-pages:
name: CloudFlare Pages 📃
runs-on: ubuntu-latest
environment:
name: ${{ inputs.branch == github.event.repository.default_branch && 'Production' || 'Preview' }}
url: ${{ steps.cf.outputs.deployment-url }}
outputs:
url: ${{ steps.cf.outputs.deployment-url }}
steps:
- name: Download workflow artifact ⬇️
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.artifact_name }}
run-id: ${{ inputs.workflow_run_id }}
github-token: ${{ github.token }}
- name: Publish to Cloudflare Pages 📃
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
id: cf
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy . --project-name=jellyfin-web --branch="${{ inputs.branch }}"
compose-comment:
name: Compose and push comment 📝
# Always run so the comment is composed for the workflow summary
if: ${{ always() && inputs.comment }}
uses: ./.github/workflows/__job_messages.yml
secrets: inherit
needs:
- cf-pages
with:
branch: ${{ inputs.branch }}
commit: ${{ inputs.commit }}
preview_url: ${{ needs.cf-pages.outputs.url }}
pr_number: ${{ inputs.pr_number }}
comment: true
in_progress: false

View file

@ -1,72 +0,0 @@
name: Job messages ⚙️
on:
workflow_call:
secrets:
JF_BOT_TOKEN:
required: true
inputs:
branch:
required: false
type: string
commit:
required: true
type: string
preview_url:
required: false
type: string
in_progress:
required: true
type: boolean
comment:
required: false
type: boolean
pr_number:
required: true
type: string
marker:
description: Hidden marker to detect PR comments composed by the bot
required: false
type: string
default: "CFPages-deployment"
jobs:
cf_pages_msg:
name: CloudFlare Pages deployment 📃🚀
runs-on: ubuntu-latest
steps:
- name: Compose message 📃
if: ${{ always() }}
id: compose
env:
COMMIT: ${{ inputs.commit }}
PREVIEW_URL: ${{ inputs.preview_url != '' && (inputs.branch != 'master' && inputs.preview_url || format('https://jellyfin-web.pages.dev ({0})', inputs.preview_url)) || 'Not available' }}
DEPLOY_STATUS: ${{ inputs.in_progress && '🔄 Deploying...' || (inputs.preview_url != '' && '✅ Deployed!' || '❌ Failure. Check workflow logs for details') }}
DEPLOYMENT_TYPE: ${{ inputs.branch != 'master' && '🔀 Preview' || '⚙️ Production' }}
WORKFLOW_RUN: ${{ !inputs.in_progress && format('**[View build logs](https://github.com/{0}/actions/runs/{1})**', github.repository, github.run_id) || '' }}
# EOF is needed for multiline environment variables in a GitHub Actions context
run: |
echo "## Cloudflare Pages deployment" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| **Latest commit** | <code>${COMMIT::7}</code> |" >> $GITHUB_STEP_SUMMARY
echo "|------------------------- |:----------------------------: |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | $DEPLOY_STATUS |" >> $GITHUB_STEP_SUMMARY
echo "| **Preview URL** | $PREVIEW_URL |" >> $GITHUB_STEP_SUMMARY
echo "| **Type** | $DEPLOYMENT_TYPE |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$WORKFLOW_RUN" >> $GITHUB_STEP_SUMMARY
COMPOSED_MSG=$(cat $GITHUB_STEP_SUMMARY)
echo "msg<<EOF" >> $GITHUB_ENV
echo "$COMPOSED_MSG" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Push comment to Pull Request 🔼
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
if: ${{ inputs.comment && steps.compose.conclusion == 'success' }}
with:
github-token: ${{ secrets.JF_BOT_TOKEN }}
message: ${{ env.msg }}
comment-tag: ${{ inputs.marker }}
pr-number: ${{ inputs.pr_number }}

View file

@ -1,46 +0,0 @@
name: Packaging 📦
on:
workflow_call:
inputs:
commit:
required: false
type: string
jobs:
run-build-prod:
name: Run production build 🏗️
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit || github.sha }}
- name: Setup node environment
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
cache: npm
check-latest: true
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run a production build
env:
JELLYFIN_VERSION: ${{ inputs.commit || github.sha }}
run: npm run build:production
- name: Update config.json for testing
run: |
jq '.multiserver=true | .servers=["https://demo.jellyfin.org/unstable"]' dist/config.json > dist/config.tmp.json
mv dist/config.tmp.json dist/config.json
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend
path: dist
if-no-files-found: error

View file

@ -1,61 +0,0 @@
name: Quality checks 👌🧪
on:
workflow_call:
inputs:
commit:
required: true
type: string
workflow_dispatch:
jobs:
dependency-review:
name: Vulnerable dependencies 🔎
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Scan
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
## Workaround from https://github.com/actions/dependency-review-action/issues/456
## TODO: Remove when necessary
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
quality:
name: Run ${{ matrix.command }} 🕵️‍♂️
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- build:es-check
- lint
- stylelint
- build:check
- test
steps:
- name: Checkout ⬇️
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit }}
show-progress: false
- name: Setup node environment ⚙️
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
cache: npm
check-latest: true
- name: Install dependencies 📦
run: npm ci --no-audit
- name: Run ${{ matrix.command }} ⚙️
run: npm run ${{ matrix.command }}

View file

@ -1,66 +0,0 @@
name: Pull Request
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
on:
pull_request:
branches:
- master
- release*
paths-ignore:
- '**/*.md'
jobs:
build:
name: Build 🏗️
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__package.yml
with:
commit: ${{ github.event.pull_request.head.sha }}
quality_checks:
name: Quality checks 👌🧪
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__quality_checks.yml
permissions: {}
with:
commit: ${{ github.event.pull_request.head.sha }}
codeql:
name: GitHub CodeQL 🔬
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__codeql.yml
permissions:
actions: read
contents: read
security-events: write
with:
commit: ${{ github.event.pull_request.head.sha }}
run-eslint:
name: Run eslint suggestions
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup node environment
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version-file: .nvmrc
cache: npm
check-latest: true
- name: Install Node.js dependencies
run: npm ci --no-audit
- name: Run eslint
uses: CatChen/eslint-suggestion-action@2b07b898c0dd5a016586d22b54282199b79cd358 # v4.1.33
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,57 +0,0 @@
name: Push & Release 🌍
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.ref }}
cancel-in-progress: true
on:
push:
branches:
- master
- release*
paths-ignore:
- '**/*.md'
jobs:
main:
name: 'Unstable release 🚀⚠️'
uses: ./.github/workflows/__package.yml
with:
commit: ${{ github.sha }}
quality_checks:
name: Quality checks 👌🧪
if: ${{ always() && !cancelled() }}
uses: ./.github/workflows/__quality_checks.yml
permissions: {}
with:
commit: ${{ github.sha }}
codeql:
name: GitHub CodeQL 🔬
uses: ./.github/workflows/__codeql.yml
permissions:
actions: read
contents: read
security-events: write
with:
commit: ${{ github.sha }}
deploy:
name: Deploy 🚀
if: ${{ github.repository == 'jellyfin/jellyfin-web' }}
uses: ./.github/workflows/__deploy.yml
needs:
- main
permissions:
contents: read
deployments: write
secrets:
JF_BOT_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
with:
branch: ${{ github.ref_name }}
commit: ${{ github.sha }}
workflow_run_id: ${{ github.run_id }}
comment: false

View file

@ -1,38 +0,0 @@
name: Scheduled tasks 🕑
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
issues:
name: Check stale issues and PRs
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
operations-per-run: 75
# Issues receive a stale warning after 120 days and close after an additional 21 days
days-before-stale: 120
days-before-close: 21
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
stale-issue-label: stale
stale-issue-message: |-
This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments.
If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label.
This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://jellyfin.org/contact).
# PRs are closed after having unresolved merge conflicts for 90 days
days-before-pr-stale: 0
days-before-pr-close: 90
only-pr-labels: merge conflict
stale-pr-label: stale
close-pr-message: |-
This PR has been closed due to having unresolved merge conflicts.

65
.github/workflows/smithflix-web.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: Smithflix / Build web app
# Builds the Smithflix web app on every push and publishes it as the "web" release in the
# public smithflix-releases repo. The Smithflix Hub on the PowerEdge checks that release
# every hour and installs new versions automatically.
on:
push:
branches:
- smithflix
workflow_dispatch:
concurrency:
group: smithflix-web
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci --no-audit
- name: Swap in the Smithflix logo and icons
run: |
cp smithflix/brand/*.png node_modules/@jellyfin/ux-web/
cp smithflix/brand/favicons/* node_modules/@jellyfin/ux-web/favicons/
- name: Build
env:
JELLYFIN_VERSION: Smithflix ${{ github.run_number }}
run: |
set -o pipefail
npm run build:production 2>&1 | tee build.log || {
grep -E "ERROR|error TS|Module not found|SyntaxError" -A3 build.log | head -40 | while IFS= read -r line; do echo "::error::${line}"; done
exit 1
}
- name: Package
run: |
test -f dist/index.html
tar -C dist -czf smithflix-web.tar.gz .
ls -la smithflix-web.tar.gz
- name: Publish to smithflix-releases
env:
GH_TOKEN: ${{ secrets.RELEASES_TOKEN }}
RELEASES_REPO: ${{ github.repository_owner }}/smithflix-releases
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Add the RELEASES_TOKEN secret to this repo (same token as the smithflix repo uses)"
exit 1
fi
gh release delete web -R "$RELEASES_REPO" --yes --cleanup-tag || true
gh release create web smithflix-web.tar.gz -R "$RELEASES_REPO" \
--title "Smithflix web app" \
--notes "Built from ${GITHUB_SHA::7}: $(git log -1 --pretty=%s)" \
--latest=false

View file

@ -1,66 +0,0 @@
name: Workflow Run
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
on:
workflow_run:
workflows:
- Pull Request
types:
- completed
jobs:
metadata:
name: Metadata
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.pr_number.outputs.pr_number }}
steps:
- name: Get Pull Request Number
id: pr_number
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
API_RESPONSE=$(gh pr list --repo "${GITHUB_REPOSITORY}" --search "${HEAD_SHA}" --state open --json number)
PR_NUMBER=$(echo "${API_RESPONSE}" | jq '.[0].number')
echo "repository: ${GITHUB_REPOSITORY}"
echo "sha: ${HEAD_SHA}"
echo "response: ${API_RESPONSE}"
echo "pr: ${PR_NUMBER}"
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
automation:
name: Automation
uses: ./.github/workflows/__automation.yml
permissions:
contents: read
issues: write
secrets:
JF_BOT_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
deploy:
name: Deploy
if: ${{ github.event.workflow_run.conclusion == 'success' }}
uses: ./.github/workflows/__deploy.yml
permissions:
contents: read
issues: write
deployments: write
secrets:
JF_BOT_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
needs:
- metadata
with:
branch: ${{ format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
commit: ${{ github.event.workflow_run.head_sha }}
workflow_run_id: ${{ github.event.workflow_run.id }}
pr_number: ${{ needs.metadata.outputs.pr_number }}
comment: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -3,6 +3,7 @@ import { AsyncRoute } from '../../../../components/router/AsyncRoute';
export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'mypreferencesmenu', page: 'user/settings' },
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'requests' },
{ path: 'search', page: 'search' },
{ path: 'userprofile', page: 'user/userprofile' }
];

View file

@ -0,0 +1,161 @@
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import Page from 'components/Page';
import {
type HubTitle,
hubFetch,
renderHubCards
} from 'components/homesections/sections/smithflix';
import ServerConnections from 'lib/jellyfin-apiclient/ServerConnections';
interface MyRequest extends HubTitle {
request: 'pending' | 'approved' | 'declined' | 'failed' | 'completed'
}
const REQUEST_LABEL: Record<string, string> = {
pending: 'Waiting for approval',
approved: 'Approved · downloading',
declined: 'Declined',
failed: 'Failed',
completed: 'On Smithflix'
};
const REQUEST_COLOR: Record<string, string> = {
pending: '#FFB547',
approved: '#1C8CFF',
declined: '#FF5C5C',
failed: '#FF5C5C',
completed: '#3DDC97'
};
const Section: FC<{ title: string, hint?: string, children: React.ReactNode }> = ({ title, hint, children }) => (
<div className='verticalSection' style={{ marginBottom: '2em' }}>
<div className='sectionTitleContainer padded-left'>
<h2 className='sectionTitle'>{title}</h2>
</div>
{hint && <p className='padded-left' style={{ opacity: 0.7, marginTop: 0 }}>{hint}</p>}
{children}
</div>
);
/** Smithflix Requests: search for anything, request it, and see your requests and the AI's picks. */
const Requests: FC = () => {
const apiClient = ServerConnections.currentApiClient();
const [ query, setQuery ] = useState('');
const [ searching, setSearching ] = useState(false);
const [ searchError, setSearchError ] = useState('');
const [ suggestCount, setSuggestCount ] = useState<number | null>(null);
const [ mine, setMine ] = useState<MyRequest[]>([]);
const [ problem, setProblem ] = useState('');
const resultsRef = useRef<HTMLDivElement>(null);
const suggestRef = useRef<HTMLDivElement>(null);
const loadMine = useCallback(() => {
if (!apiClient) return;
hubFetch(apiClient, '/requests')
.then(body => setMine(body.results || []))
.catch(err => setProblem(err.message));
}, [ apiClient ]);
useEffect(() => {
if (!apiClient) return;
hubFetch(apiClient, '/suggestions')
.then(body => {
const titles: HubTitle[] = body.results || [];
setSuggestCount(titles.length);
if (suggestRef.current) renderHubCards(suggestRef.current, apiClient, titles, loadMine);
})
.catch(err => {
setSuggestCount(0);
setProblem(err.message);
});
loadMine();
}, [ apiClient, loadMine ]);
useEffect(() => {
const q = query.trim();
if (!apiClient || q.length < 2) {
if (resultsRef.current) resultsRef.current.innerHTML = '';
setSearchError('');
return;
}
const timer = setTimeout(() => {
setSearching(true);
hubFetch(apiClient, '/search?q=' + encodeURIComponent(q))
.then(body => {
const titles: HubTitle[] = body.results || [];
setSearchError(titles.length ? '' : 'Nothing found. Try a different spelling.');
if (resultsRef.current) renderHubCards(resultsRef.current, apiClient, titles, loadMine);
})
.catch(err => setSearchError(err.message))
.finally(() => setSearching(false));
}, 450);
return () => clearTimeout(timer);
}, [ apiClient, query, loadMine ]);
return (
<Page
id='smithflixRequestsPage'
title='Requests'
className='mainAnimatedPage libraryPage noSecondaryNavPage'
>
<div className='padded-top' style={{ paddingBottom: '3em' }}>
{problem && (
<p className='padded-left padded-right' style={{ color: '#FFB547' }}>{problem}</p>
)}
<Section
title='Request a movie or show'
hint="Search for anything. If it isn't on Smithflix yet, request it and it'll be added."
>
<div className='padded-left padded-right' style={{ maxWidth: '40em' }}>
<input
id='smithflixRequestSearch'
type='search'
className='emby-input'
placeholder='Search movies and shows'
autoComplete='off'
value={query}
onChange={e => setQuery(e.target.value)}
style={{ fontSize: '1.2em', padding: '.6em .9em', borderRadius: '10px', width: '100%' }}
/>
{searching && <p style={{ opacity: 0.7 }}>Searching…</p>}
{searchError && <p style={{ opacity: 0.8 }}>{searchError}</p>}
</div>
<div ref={resultsRef} className='itemsContainer padded-left padded-right vertical-wrap' style={{ marginTop: '1em' }} />
</Section>
<Section
title='Not on Smithflix yet'
hint={suggestCount === 0 ? 'Your picks appear here after the nightly AI run.' : 'Picked for you each night by the Smithflix AI.'}
>
<div ref={suggestRef} className='itemsContainer padded-left padded-right vertical-wrap' />
</Section>
<Section title='Your requests' hint={mine.length ? undefined : "You haven't requested anything yet."}>
<div className='padded-left padded-right' style={{ display: 'grid', gap: '.6em', maxWidth: '48em' }}>
{mine.map(r => (
<div
key={`${r.type}-${r.tmdbId}`}
style={{ display: 'flex', alignItems: 'center', gap: '1em', padding: '.6em', borderRadius: '12px', background: 'rgba(255,255,255,.05)', border: '1px solid rgba(255,255,255,.07)' }}
>
{r.poster ? (
<img src={r.poster} alt='' style={{ width: '3.2em', borderRadius: '.4em' }} />
) : null}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{r.title}{r.year ? ` (${r.year})` : ''}</div>
<div style={{ opacity: 0.7, fontSize: '.9em' }}>{r.type === 'tv' ? 'Series' : 'Movie'}</div>
</div>
<span style={{ fontSize: '.85em', fontWeight: 600, color: REQUEST_COLOR[r.request] || '#ccc' }}>
{REQUEST_LABEL[r.request] || r.request}
</span>
</div>
))}
</div>
</Section>
</div>
</Page>
);
};
export default Requests;

View file

@ -143,7 +143,7 @@ const UserViewNav = () => {
startIcon={<Icon>{navItem.icon || 'link'}</Icon>}
component='a'
href={navItem.url}
target='_blank'
target={navItem.url.startsWith('#') ? undefined : '_blank'}
rel='noopener noreferrer'
>
{navItem.name}

View file

@ -37,7 +37,7 @@ const UserViewsMenu: FC<UserViewsMenuProps> = ({
key={navItem.name}
component='a'
href={navItem.url}
target='_blank'
target={navItem.url.startsWith('#') ? undefined : '_blank'}
rel='noopener noreferrer'
onClick={onMenuClose}
>

View file

@ -68,7 +68,7 @@ const MainDrawerContent = () => {
<ListItemButton
component='a'
href={menuLink.url}
target='_blank'
target={menuLink.url.startsWith('#') ? undefined : '_blank'}
rel='noopener noreferrer'
>
<ListItemIcon>

View file

@ -15,6 +15,7 @@ export const ASYNC_USER_ROUTES: AsyncRoute[] = [
{ path: 'mypreferencesdisplay', page: 'user/display', type: AppType.Modern },
{ path: 'mypreferencesmenu', page: 'user/settings' },
{ path: 'quickconnect', page: 'quickConnect' },
{ path: 'requests' },
{ path: 'search' },
{ path: 'tv', page: 'shows', type: AppType.Modern },
{ path: 'userprofile', page: 'user/userprofile' }

View file

@ -0,0 +1,2 @@
// The Requests page is shared with the legacy layout.
export { default } from 'apps/legacy/routes/requests';

View file

@ -13,6 +13,7 @@ import { loadLiveTV } from './sections/liveTv';
import { loadNextUp } from './sections/nextUp';
import { loadRecentlyAdded } from './sections/recentlyAdded';
import { loadResume } from './sections/resume';
import { loadSmithflixAiRows, loadSmithflixSuggestions } from './sections/smithflix';
import 'elements/emby-button/paper-icon-button-light';
import 'elements/emby-itemscontainer/emby-itemscontainer';
@ -74,11 +75,28 @@ export function loadSections(elem, apiClient, user, userSettings) {
elem.innerHTML = html;
elem.classList.add('homeSectionsContainer');
const promises = getAllSectionsToShow(userSettings)
const sectionTypes = getAllSectionsToShow(userSettings);
const promises = sectionTypes
.map((section, index) => (
loadSection(elem, apiClient, user, userSettings, userViews, section, index)
));
// Smithflix: AI rows and "Not on Smithflix yet", right after Continue Watching
const anchorIndex = Math.max(0, sectionTypes.indexOf(HomeSectionType.Resume));
const anchor = elem.querySelector('.section' + anchorIndex);
const aiElem = document.createElement('div');
aiElem.className = 'smithflixAiRows';
const suggestElem = document.createElement('div');
suggestElem.className = 'verticalSection smithflixSuggestions hide';
if (anchor) {
anchor.after(aiElem, suggestElem);
} else {
elem.append(aiElem, suggestElem);
}
const sfxOptions = { enableOverflow: enableScrollX() };
promises.push(loadSmithflixAiRows(aiElem, apiClient, sfxOptions));
promises.push(loadSmithflixSuggestions(suggestElem, apiClient, sfxOptions));
return Promise.all(promises)
// Timeout for polyfilled CustomElements (webOS 1.2)
.then(() => new Promise((resolve) => setTimeout(resolve, 0)))

View file

@ -0,0 +1,219 @@
/**
* Smithflix home rows:
* - the nightly AI playlists ("Because you watched…", seasonal picks, Family Movie Night, Hidden Gems)
* - "Not on Smithflix yet": AI picks of titles that aren't on the server, with a Request button
* (served by the Smithflix Hub at /api on the same site).
*/
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
import type { ApiClient } from 'jellyfin-apiclient';
import cardBuilder from 'components/cardbuilder/cardBuilder';
import { getPortraitShape } from 'components/cardbuilder/utils/shape';
import dialogHelper from 'components/dialogHelper/dialogHelper';
import toast from 'components/toast/toast';
import escapeHtml from 'escape-html';
import type { SectionContainerElement, SectionOptions } from './section';
const AI_PREFIX = 'Because you watched';
const AI_SEASONAL = [ 'Halloween Picks', 'Holiday Picks', 'Date Night Picks' ];
const AI_FAMILY = 'Family Movie Night';
const AI_GEMS = 'Hidden Gems';
export interface HubTitle {
tmdbId: number
type: 'movie' | 'tv'
title: string
year?: number | null
overview?: string
poster?: string | null
reason?: string
status?: string
}
const REQUESTED = [ 'requested', 'processing', 'partial', 'available' ];
function aiOrder(name: string) {
if (name.startsWith(AI_PREFIX)) return 0;
if (AI_SEASONAL.includes(name)) return 1;
if (name === AI_FAMILY) return 2;
if (name === AI_GEMS) return 3;
return -1;
}
function rowHtml(title: string, enableOverflow: boolean) {
let html = '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
html += '<h2 class="sectionTitle sectionTitle-cards">' + escapeHtml(title) + '</h2>';
html += '</div>';
if (enableOverflow) {
html += '<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true">';
html += '<div is="emby-itemscontainer" class="itemsContainer scrollSlider focuscontainer-x" data-monitor="markplayed"></div>';
html += '</div>';
} else {
html += '<div is="emby-itemscontainer" class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x" data-monitor="markplayed"></div>';
}
return html;
}
/** One row per AI playlist, in the same order as the TV app. */
export function loadSmithflixAiRows(elem: HTMLElement, apiClient: ApiClient, options: SectionOptions) {
const userId = apiClient.getCurrentUserId();
return apiClient.getItems(userId, { IncludeItemTypes: 'Playlist', Recursive: true })
.then(result => {
const playlists = (result.Items || [])
.filter(p => aiOrder(p.Name || '') >= 0)
.sort((a, b) => aiOrder(a.Name || '') - aiOrder(b.Name || ''));
elem.innerHTML = '';
for (const playlist of playlists) {
const section = document.createElement('div');
section.className = 'verticalSection hide';
section.innerHTML = rowHtml(playlist.Name || '', options.enableOverflow);
elem.appendChild(section);
const container = section.querySelector('.itemsContainer') as SectionContainerElement | null;
if (!container) continue;
container.fetchData = () => apiClient.getItems(userId, {
ParentId: playlist.Id,
Limit: 30,
Fields: 'PrimaryImageAspectRatio',
ImageTypeLimit: 1,
EnableImageTypes: 'Primary,Backdrop,Thumb'
});
container.getItemsHtml = (items: BaseItemDto[]) => cardBuilder.getCardsHtml({
items,
shape: getPortraitShape(options.enableOverflow),
showTitle: true,
showYear: true,
centerText: true,
overlayPlayButton: true,
lazy: true,
context: 'home',
allowBottomPadding: !options.enableOverflow
});
container.parentContainer = section;
}
})
.catch(err => {
console.warn('[Smithflix] AI rows failed', err);
elem.innerHTML = '';
});
}
/* ---------------- Smithflix Hub (requests) ---------------- */
export function hubFetch(apiClient: ApiClient, path: string, init: RequestInit = {}) {
return fetch('/api' + path, {
...init,
headers: {
'Authorization': `MediaBrowser Token="${apiClient.accessToken()}"`,
'Content-Type': 'application/json',
...(init.headers || {})
}
}).then(async r => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error || 'Something went wrong.');
return body;
});
}
function cardHtml(t: HubTitle, i: number) {
const requested = REQUESTED.includes(t.status || '');
const img = t.poster ?
`<img src="${escapeHtml(t.poster)}" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;">` :
`<div style="position:absolute;inset:0;display:grid;place-items:center;padding:1em;text-align:center;font-weight:600">${escapeHtml(t.title)}</div>`;
return `<button type="button" class="card overflowPortraitCard card-hoverable sfx-card" data-index="${i}" style="background:none;border:0;padding:0;color:inherit;text-align:center;cursor:pointer">
<div class="cardBox cardBox-bottompadded">
<div class="cardScalable" style="position:relative;padding-bottom:150%;overflow:hidden;background:rgba(255,255,255,.05)">
${img}
<span class="sfx-badge${requested ? ' requested' : ''}">${requested ? 'REQUESTED' : 'REQUEST'}</span>
</div>
<div class="cardText cardTextCentered cardText-first"><bdi>${escapeHtml(t.title)}</bdi></div>
<div class="cardText cardTextCentered cardText-secondary"><bdi>${t.year ?? ''}${t.year ? ' · ' : ''}${t.type === 'tv' ? 'Series' : 'Movie'}</bdi></div>
</div>
</button>`;
}
/** "Request this?" dialog, shared with the Requests page. */
export function showRequestDialog(apiClient: ApiClient, t: HubTitle, onDone?: (status: string) => void) {
const requested = REQUESTED.includes(t.status || '');
const dlg = dialogHelper.createDialog({ size: 'small', removeOnClose: true, scrollY: false }) as HTMLElement;
dlg.classList.add('formDialog');
dlg.innerHTML = `
<div class="formDialogHeader">
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="Close"><span class="material-icons close" aria-hidden="true"></span></button>
<h3 class="formDialogHeaderTitle">${escapeHtml(t.title)}${t.year ? ' (' + t.year + ')' : ''}</h3>
</div>
<div class="formDialogContent" style="padding:1em 1.5em 1.5em">
<div style="display:flex;gap:1.2em;align-items:flex-start;flex-wrap:wrap">
${t.poster ? `<img src="${escapeHtml(t.poster)}" alt="" style="width:9em;border-radius:.6em">` : ''}
<div style="flex:1;min-width:14em">
${t.reason ? `<p style="color:#0FE6FF;margin-top:0">${escapeHtml(t.reason)}</p>` : ''}
<p style="opacity:.85">${escapeHtml(t.overview || 'No description available.')}</p>
<button is="emby-button" type="button" class="raised button-submit block btnRequest" ${requested ? 'disabled' : ''}>
<span>${requested ? 'Already requested' : (t.type === 'tv' ? 'Request series' : 'Request movie')}</span>
</button>
</div>
</div>
</div>`;
dlg.querySelector('.btnCancel')?.addEventListener('click', () => dialogHelper.close(dlg));
const btn = dlg.querySelector('.btnRequest') as HTMLButtonElement | null;
btn?.addEventListener('click', () => {
btn.disabled = true;
hubFetch(apiClient, '/request', { method: 'POST', body: JSON.stringify({ type: t.type, tmdbId: t.tmdbId }) })
.then(res => {
toast(res.message || 'Requested!');
onDone?.('requested');
dialogHelper.close(dlg);
})
.catch(err => {
toast(err.message);
btn.disabled = false;
});
});
dialogHelper.open(dlg);
}
export function renderHubCards(container: HTMLElement, apiClient: ApiClient, titles: HubTitle[], onRequested?: () => void) {
container.innerHTML = titles.map(cardHtml).join('');
container.querySelectorAll<HTMLButtonElement>('.sfx-card').forEach(card => {
card.addEventListener('click', () => {
const t = titles[Number(card.dataset.index)];
showRequestDialog(apiClient, t, status => {
t.status = status;
const badge = card.querySelector('.sfx-badge');
if (badge) {
badge.textContent = 'REQUESTED';
badge.classList.add('requested');
}
onRequested?.();
});
});
});
}
/** "Not on Smithflix yet" row. */
export function loadSmithflixSuggestions(elem: HTMLElement, apiClient: ApiClient, options: SectionOptions) {
return hubFetch(apiClient, '/suggestions')
.then(body => {
const titles: HubTitle[] = body.results || [];
if (!titles.length) {
elem.innerHTML = '';
return;
}
let html = '<div class="sectionTitleContainer sectionTitleContainer-cards padded-left">';
html += '<a href="#/requests" class="button-flat button-flat-mini sectionTitleTextButton" style="text-decoration:none">';
html += '<h2 class="sectionTitle sectionTitle-cards">Not on Smithflix yet</h2>';
html += '<span class="material-icons chevron_right" aria-hidden="true"></span></a></div>';
html += options.enableOverflow ?
'<div is="emby-scroller" class="padded-top-focusscale padded-bottom-focusscale" data-centerfocus="true"><div class="itemsContainer scrollSlider focuscontainer-x sfx-items"></div></div>' :
'<div class="itemsContainer padded-left padded-right vertical-wrap focuscontainer-x sfx-items"></div>';
elem.innerHTML = html;
elem.classList.remove('hide');
const container = elem.querySelector('.sfx-items') as HTMLElement | null;
if (container) renderHubCards(container, apiClient, titles);
})
.catch(err => {
console.warn('[Smithflix] suggestions unavailable', err);
elem.innerHTML = '';
});
}

View file

@ -2,35 +2,53 @@
"includeCorsCredentials": false,
"multiserver": false,
"themes": [
{
"name": "Smithflix",
"id": "smithflix",
"color": "#090C14",
"default": true
},
{
"name": "Apple TV",
"id": "appletv",
"color": "#bcbcbc"
}, {
},
{
"name": "Blue Radiance",
"id": "blueradiance",
"color": "#011432"
}, {
},
{
"name": "Dark",
"id": "dark",
"color": "#202020",
"default": true
}, {
"color": "#202020"
},
{
"name": "Light",
"id": "light",
"color": "#303030"
}, {
},
{
"name": "Purple Haze",
"id": "purplehaze",
"color": "#000420"
}, {
},
{
"name": "WMC",
"id": "wmc",
"color": "#0c2450"
}
],
"menuLinks": [],
"servers": [],
"menuLinks": [
{
"name": "Requests",
"icon": "add_circle",
"url": "#/requests"
}
],
"servers": [
"https://media.smithflix.us"
],
"plugins": [
"playAccessValidation/plugin",
"experimentalWarnings/plugin",

View file

@ -9,7 +9,7 @@
<meta http-equiv="X-UA-Compatibility" content="IE=Edge">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="Jellyfin">
<meta name="application-name" content="Smithflix">
<meta name="robots" content="noindex, nofollow, noarchive">
<meta name="referrer" content="no-referrer">
@ -20,7 +20,7 @@
<meta name="msapplication-TileImage" content="../node_modules/@jellyfin/ux-web/favicons/touchicon144.png">
<meta name="msapplication-TileColor" content="#333333">
<title>Jellyfin</title>
<title>Smithflix</title>
<style>
.transparentDocument,

View file

@ -1,11 +1,11 @@
{
"name": "Jellyfin",
"description": "The Free Software Media System",
"name": "Smithflix",
"description": "Smithflix: movies, shows and live TV",
"lang": "en-US",
"short_name": "Jellyfin",
"short_name": "Smithflix",
"start_url": "index.html#/home",
"theme_color": "#101010",
"background_color": "#101010",
"theme_color": "#090C14",
"background_color": "#090C14",
"display": "standalone",
"icons": [
{

View file

@ -444,8 +444,11 @@ function updateLibraryMenu(user) {
links.forEach(link => {
const option = document.createElement('a', 'emby-linkbutton');
option.classList.add('navMenuOption', 'lnkMediaFolder');
option.rel = 'noopener noreferrer';
option.target = '_blank';
// Smithflix: in-app links (e.g. #/requests) open in the same tab
if (!link.url.startsWith('#')) {
option.rel = 'noopener noreferrer';
option.target = '_blank';
}
option.href = link.url;
const icon = document.createElement('span');

View file

@ -6,6 +6,7 @@ import blueradiance from './blueradiance';
import dark from './dark';
import light from './light';
import purplehaze from './purplehaze';
import smithflix from './smithflix';
import wmc from './wmc';
/** The default theme containing all color scheme variants. */
@ -15,7 +16,7 @@ const DEFAULT_THEME = createTheme({
colorSchemeSelector: '[data-theme="%s"]',
disableCssColorScheme: true
},
defaultColorScheme: 'dark',
defaultColorScheme: 'smithflix',
...DEFAULT_THEME_OPTIONS,
colorSchemes: {
appletv,
@ -23,6 +24,7 @@ const DEFAULT_THEME = createTheme({
dark,
light,
purplehaze,
smithflix,
wmc
}
});

View file

@ -0,0 +1,40 @@
import { buildCustomColorScheme } from 'themes/utils';
/** The "Smithflix" color scheme: deep navy with the Smithflix blue/cyan accents. */
const theme = buildCustomColorScheme({
palette: {
primary: {
main: '#1C8CFF',
dark: '#0A63F2',
light: '#0FE6FF'
},
secondary: {
main: '#0FE6FF'
},
background: {
default: '#090C14',
paper: '#111A2C'
},
text: {
primary: '#E9EEF8',
secondary: 'rgba(233, 238, 248, 0.72)'
},
action: {
focus: '#1C8CFF',
hover: 'rgba(28, 140, 255, 0.18)'
},
AppBar: {
defaultBg: 'rgba(9, 12, 20, 0.72)'
},
Button: {
inheritContainedBg: 'rgba(255, 255, 255, 0.08)',
inheritContainedHoverBg: 'rgba(28, 140, 255, 0.28)'
},
SnackbarContent: {
bg: '#111A2C',
color: '#E9EEF8'
}
}
});
export default theme;

View file

@ -0,0 +1,141 @@
@use '../_base/theme' with (
// Theme options
$dark: true,
// Color palette
$background-default: #090C14,
$background-paper: #111A2C,
$primary-main: #1C8CFF,
$primary-dark: #0A63F2,
$primary-light: #0FE6FF,
$primary-hover: rgba(28, 140, 255, 0.2),
$primary-contrastText: #fff,
$secondary-main: #0FE6FF,
$text-primary: #E9EEF8,
$text-secondary: rgba(233, 238, 248, 0.72),
$action-focus: #1C8CFF,
$action-hover: rgba(28, 140, 255, 0.18),
$divider: rgba(255, 255, 255, 0.10),
// Components
$appBar-defaultBg: rgba(9, 12, 20, 0.72),
$appBar-transparentBg: rgba(9, 12, 20, 0.35),
$surface-overlay: rgba(17, 26, 44, 0.72),
$card-borderRadius: 0.6em,
$filledInput-bg: rgba(255, 255, 255, 0.07),
$button-inheritContainedBg: rgba(255, 255, 255, 0.08),
$button-inheritContainedHoverBg: rgba(28, 140, 255, 0.28),
$snackbarContent-bg: #111A2C,
$snackbarContent-color: #E9EEF8
);
/* ---------- Smithflix glass ---------- */
/* Page background: soft blue glow on navy */
.backgroundContainer,
body {
background-color: #090C14;
}
.backgroundContainer:not(.withBackdrop) {
background-image:
radial-gradient(1200px 600px at 85% -10%, rgba(28, 140, 255, 0.18), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(15, 230, 255, 0.08), transparent 60%);
}
/* Frosted header bar */
.skinHeader,
.skinHeader-withBackground,
.MuiAppBar-root {
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* Glass panels: menus, dialogs, lists */
.dialog,
.actionSheet,
.paperList,
.visualCardBox,
.MuiPaper-root {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.dialog,
.actionSheet {
background-color: rgba(13, 20, 36, 0.86);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
}
/* Rounded, gradient buttons */
.raised,
.button-submit,
.emby-button.raised {
border-radius: 10px;
}
.button-submit,
.btnPlay.raised,
.emby-button.button-submit {
background: linear-gradient(135deg, #1C8CFF, #0A63F2);
color: #fff;
}
/* Card focus: blue-to-cyan outline instead of the white box */
.card:focus .cardBox.visualCardBox,
.card:focus .cardBox:not(.visualCardBox) .cardScalable,
.card.show-focus:not(.show-animation) .cardBox.visualCardBox,
.card.show-focus:not(.show-animation) .cardBox:not(.visualCardBox) .cardScalable {
border-color: transparent;
box-shadow: 0 0 0 3px #1C8CFF, 0 0 18px rgba(15, 230, 255, 0.45);
}
.cardImageContainer,
.cardScalable {
border-radius: 0.6em;
}
/* Section titles a touch bolder */
.sectionTitle {
font-weight: 600;
letter-spacing: 0.01em;
}
/* Card background placeholders (no artwork) */
.defaultCardBackground1 { background-color: #0F2A52; }
.defaultCardBackground2 { background-color: #123E78; }
.defaultCardBackground3 { background-color: #0A63F2; }
.defaultCardBackground4 { background-color: #10334D; }
.defaultCardBackground5 { background-color: #0B4A6E; }
/* Detail ribbon on item details pages */
.detailRibbon {
background: linear-gradient(90deg, rgba(10, 99, 242, 0.25), rgba(9, 12, 20, 0.85) 45%, rgba(15, 230, 255, 0.12));
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
.layout-tv & {
background: none;
backdrop-filter: none;
}
}
/* Smithflix rows from the hub ("Not on Smithflix yet") */
.sfx-badge {
position: absolute;
top: 0.5em;
right: 0.5em;
padding: 0.15em 0.5em;
border-radius: 6px;
font-size: 0.7em;
font-weight: 700;
letter-spacing: 0.04em;
color: #fff;
background: rgba(10, 99, 242, 0.88);
}
.sfx-badge.requested {
background: rgba(30, 122, 74, 0.9);
}

View file

@ -6,6 +6,7 @@ declare module '@mui/material/styles' {
appletv: true;
blueradiance: true;
purplehaze: true;
smithflix: true;
wmc: true;
}