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
18
.github/workflows/__automation.yml
vendored
|
|
@ -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 }}
|
||||
40
.github/workflows/__codeql.yml
vendored
|
|
@ -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}}'
|
||||
86
.github/workflows/__deploy.yml
vendored
|
|
@ -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
|
||||
72
.github/workflows/__job_messages.yml
vendored
|
|
@ -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 }}
|
||||
46
.github/workflows/__package.yml
vendored
|
|
@ -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
|
||||
61
.github/workflows/__quality_checks.yml
vendored
|
|
@ -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 }}
|
||||
66
.github/workflows/pull_request.yml
vendored
|
|
@ -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 }}
|
||||
57
.github/workflows/push.yml
vendored
|
|
@ -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
|
||||
38
.github/workflows/schedule.yml
vendored
|
|
@ -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
|
|
@ -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
|
||||
66
.github/workflows/workflow_run.yml
vendored
|
|
@ -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
|
||||
BIN
smithflix/brand/banner-dark.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
smithflix/brand/banner-light.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
smithflix/brand/favicons/favicon.ico
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
smithflix/brand/favicons/touchicon.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
smithflix/brand/favicons/touchicon114.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
smithflix/brand/favicons/touchicon144.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
smithflix/brand/favicons/touchicon512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
smithflix/brand/favicons/touchicon72.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
smithflix/brand/icon-transparent.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -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' }
|
||||
];
|
||||
|
|
|
|||
161
src/apps/legacy/routes/requests.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const MainDrawerContent = () => {
|
|||
<ListItemButton
|
||||
component='a'
|
||||
href={menuLink.url}
|
||||
target='_blank'
|
||||
target={menuLink.url.startsWith('#') ? undefined : '_blank'}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<ListItemIcon>
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
2
src/apps/modern/routes/requests.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// The Requests page is shared with the legacy layout.
|
||||
export { default } from 'apps/legacy/routes/requests';
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
219
src/components/homesections/sections/smithflix.ts
Normal 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 = '';
|
||||
});
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
40
src/themes/smithflix/index.ts
Normal 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;
|
||||
141
src/themes/smithflix/theme.scss
Normal 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);
|
||||
}
|
||||
1
src/themes/styles.d.ts
vendored
|
|
@ -6,6 +6,7 @@ declare module '@mui/material/styles' {
|
|||
appletv: true;
|
||||
blueradiance: true;
|
||||
purplehaze: true;
|
||||
smithflix: true;
|
||||
wmc: true;
|
||||
}
|
||||
|
||||
|
|
|
|||