mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
Jellyfin Integration. Assisted by Claude Fable 5.
This commit is contained in:
1 parent
b9767a7471
commit
f637ba0191
32 files changed
+2802
-171
No files matched your search
+144
-18
@@ -18,10 +18,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 私人FM -->
|
||||
<section class="section">
|
||||
<!-- 私人FM(音源按 enabledProviders 门控;Jellyfin 电台优先) -->
|
||||
<section v-if="fmCardCount > 0" class="section">
|
||||
<h2 class="section-title">私人FM</h2>
|
||||
<div class="fm-card hover-scale" @click="playFm('netease')">
|
||||
<div v-if="enabled('jellyfin')" class="fm-card hover-scale" @click="playFm('jellyfin')">
|
||||
<div class="fm-icon-wrapper jellyfin">
|
||||
<Icon icon="mdi:jellyfish" class="fm-icon" />
|
||||
</div>
|
||||
<div class="fm-info">
|
||||
<div class="fm-title">Jellyfin 电台</div>
|
||||
<div class="fm-desc">从收藏出发的 Instant Mix 歌曲流</div>
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="enabled('netease')" class="fm-card hover-scale" @click="playFm('netease')">
|
||||
<div class="fm-icon-wrapper">
|
||||
<Icon icon="mdi:radio" class="fm-icon" />
|
||||
</div>
|
||||
@@ -31,7 +41,7 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||
<div v-if="enabled('qq') && store.authStatus.qq" class="fm-card hover-scale" @click="playFm('qq')">
|
||||
<div class="fm-icon-wrapper qq">
|
||||
<Icon icon="mdi:radar" class="fm-icon" />
|
||||
</div>
|
||||
@@ -41,7 +51,7 @@
|
||||
</div>
|
||||
<Icon icon="mdi:play-circle" class="fm-play-icon" />
|
||||
</div>
|
||||
<div v-if="store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div v-if="enabled('kugou') && store.authStatus.kugou" class="fm-card hover-scale" @click="playFm('kugou')">
|
||||
<div class="fm-icon-wrapper kugou">
|
||||
<Icon icon="mdi:radio-tower" class="fm-icon" />
|
||||
</div>
|
||||
@@ -53,6 +63,76 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 最近添加 -->
|
||||
<section class="section" v-if="store.jellyfinLatestAlbums.length > 0">
|
||||
<h2 class="section-title">最近添加</h2>
|
||||
<div class="playlist-grid">
|
||||
<RouterLink
|
||||
v-for="al in store.jellyfinLatestAlbums"
|
||||
:key="al.id"
|
||||
:to="`/album/${al.id}?platform=jellyfin`"
|
||||
class="playlist-card hover-scale"
|
||||
>
|
||||
<CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
|
||||
<div class="playlist-name">{{ al.name }}</div>
|
||||
<div class="playlist-count">{{ al.artist }}</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 播放最多 -->
|
||||
<section class="section" v-if="store.jellyfinMostPlayed.length > 0">
|
||||
<h2 class="section-title">播放最多</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in store.jellyfinMostPlayed"
|
||||
:key="song.id"
|
||||
class="daily-card hover-scale"
|
||||
@click="store.playSong(song)"
|
||||
>
|
||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||
<div class="daily-name">{{ song.name }}</div>
|
||||
<div class="daily-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 收藏曲目 -->
|
||||
<section class="section" v-if="store.jellyfinFavorites.length > 0">
|
||||
<h2 class="section-title">
|
||||
<Icon icon="mdi:star" style="color: var(--brand-jellyfin)" />
|
||||
Jellyfin 收藏
|
||||
</h2>
|
||||
<div class="daily-grid">
|
||||
<div
|
||||
v-for="song in store.jellyfinFavorites"
|
||||
:key="song.id"
|
||||
class="daily-card hover-scale"
|
||||
@click="store.playSong(song)"
|
||||
>
|
||||
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
|
||||
<div class="daily-name">{{ song.name }}</div>
|
||||
<div class="daily-artist">{{ song.artist }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Jellyfin: 流派 -->
|
||||
<section class="section" v-if="store.jellyfinGenres.length > 0">
|
||||
<h2 class="section-title">流派</h2>
|
||||
<div class="genre-chips">
|
||||
<button
|
||||
v-for="g in store.jellyfinGenres"
|
||||
:key="g.id"
|
||||
class="genre-chip"
|
||||
@click="store.playJellyfinGenre(g.id)"
|
||||
>
|
||||
<Icon icon="mdi:music-note" />
|
||||
{{ g.name }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 每日推荐 -->
|
||||
<section class="section" v-if="dailyAvailable.length > 0">
|
||||
<h2 class="section-title">
|
||||
@@ -177,16 +257,31 @@ const store = usePlayerStore();
|
||||
const USER_PLAYLIST_LIMIT = 20;
|
||||
const userPlaylistsExpanded = ref(false);
|
||||
|
||||
// Available sources per section.
|
||||
// Recommend playlists work anonymously on netease, so it's always available;
|
||||
// Server-side source gate (enabledProviders).
|
||||
const enabled = (p: string) => store.enabledProviders.includes(p);
|
||||
const fmCardCount = computed(() =>
|
||||
['jellyfin', 'netease'].filter(enabled).length +
|
||||
(enabled('qq') && store.authStatus.qq ? 1 : 0) +
|
||||
(enabled('kugou') && store.authStatus.kugou ? 1 : 0),
|
||||
);
|
||||
|
||||
// Available sources per section. Jellyfin has no daily/recommend concept —
|
||||
// it gets its own home sections instead — so those two tab bars only ever
|
||||
// carry the legacy sources (which is also what their store maps are keyed by).
|
||||
type LegacySource = Exclude<Source, 'jellyfin'>;
|
||||
|
||||
// Recommend playlists work anonymously on netease (when enabled);
|
||||
// QQ requires login. This intentionally differs from dailyAvailable/userAvailable.
|
||||
const recommendAvailable = computed<Source[]>(() => {
|
||||
const s: Source[] = ['netease'];
|
||||
if (store.authStatus.qq) s.push('qq');
|
||||
if (store.authStatus.kugou) s.push('kugou');
|
||||
const recommendAvailable = computed<LegacySource[]>(() => {
|
||||
const s: LegacySource[] = [];
|
||||
if (enabled('netease')) s.push('netease');
|
||||
if (enabled('qq') && store.authStatus.qq) s.push('qq');
|
||||
if (enabled('kugou') && store.authStatus.kugou) s.push('kugou');
|
||||
return s;
|
||||
});
|
||||
const dailyAvailable = computed<Source[]>(() => store.availableSources);
|
||||
const dailyAvailable = computed<LegacySource[]>(() =>
|
||||
store.availableSources.filter((s): s is LegacySource => s !== 'jellyfin'),
|
||||
);
|
||||
const userAvailable = computed<Source[]>(() => store.availableSources);
|
||||
|
||||
// Persisted active source per section.
|
||||
@@ -199,14 +294,14 @@ watch(dailySource, (v) => saveTabSource('home.daily', v));
|
||||
watch(userSource, (v) => saveTabSource('home.user', v));
|
||||
|
||||
// Fallback when persisted source is no longer available.
|
||||
const recommendSourceSafe = computed<Source>(() =>
|
||||
recommendAvailable.value.includes(recommendSource.value)
|
||||
? recommendSource.value
|
||||
const recommendSourceSafe = computed<LegacySource>(() =>
|
||||
(recommendAvailable.value as Source[]).includes(recommendSource.value)
|
||||
? (recommendSource.value as LegacySource)
|
||||
: recommendAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
const dailySourceSafe = computed<Source>(() =>
|
||||
dailyAvailable.value.includes(dailySource.value)
|
||||
? dailySource.value
|
||||
const dailySourceSafe = computed<LegacySource>(() =>
|
||||
(dailyAvailable.value as Source[]).includes(dailySource.value)
|
||||
? (dailySource.value as LegacySource)
|
||||
: dailyAvailable.value[0] ?? 'netease'
|
||||
);
|
||||
const userSourceSafe = computed<Source>(() =>
|
||||
@@ -373,6 +468,11 @@ onMounted(() => {
|
||||
&.kugou {
|
||||
background: linear-gradient(135deg, var(--brand-kugou), #1d7fd1);
|
||||
}
|
||||
|
||||
// Jellyfin's brand gradient (purple → blue).
|
||||
&.jellyfin {
|
||||
background: linear-gradient(135deg, var(--brand-jellyfin), #00a4dc);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-icon {
|
||||
@@ -472,4 +572,30 @@ onMounted(() => {
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// Jellyfin 流派
|
||||
.genre-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.genre-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--brand-jellyfin);
|
||||
background: var(--brand-jellyfin-12);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user