diff --git a/registry/dist/plugins/video/mpris.js b/registry/dist/plugins/video/mpris.js new file mode 100644 index 0000000000..62a39243ca --- /dev/null +++ b/registry/dist/plugins/video/mpris.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports["video/mpris"]=t():e["video/mpris"]=t()}(globalThis,()=>(()=>{"use strict";var e={d:(t,a)=>{for(var n in a)e.o(a,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:a[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)},t={};e.d(t,{plugin:()=>l});const a=coreApis.observer,n=coreApis.componentApis.video.playerAgent,i=coreApis.componentApis.video.videoInfo,o=(e,t)=>{try{navigator.mediaSession.setActionHandler(e,t)}catch{}},r=e=>{const t=new URL(window.location.href);t.searchParams.set("p",String(e)),window.location.href=t.toString()},s=e=>{window.location.href=`https://www.bilibili.com/video/${e}`},l={name:"video.mpris",displayName:"MPRIS 媒体控制",description:"通过 Media Session API 将 Bilibili 视频集成到系统媒体控制(MPRIS):\n- 标题为视频标题(多P时附带分P标题)\n- 艺术家为 UP 主名称\n- 专辑封面为视频封面\n- 播放进度为视频进度\n- 支持快进、快退、倍速\n- 分P视频/合集支持上一曲和下一曲切换,并在切换视频时自动更新以上信息",setup:()=>{if(!("mediaSession"in navigator))return;const e=navigator.mediaSession;let t=null,l=null,c=[];const d=()=>{const a=t;if(a&&!isNaN(a.duration)&&0!==a.duration)try{e.setPositionState({duration:a.duration,position:Math.min(a.currentTime,a.duration),playbackRate:a.playbackRate})}catch{}};(0,a.videoChange)(async a=>{let{aid:p,cid:u}=a;null!==l&&(clearInterval(l),l=null),c.forEach(e=>e()),c=[];const m=await n.playerAgent.query.video.element();if(t=m,!m)return;let f;try{f=await new i.VideoInfo(String(p)).fetchInfo()}catch{return}const v=Number(u),{pages:g}=f,y=g.length>1,b=y?g.findIndex(e=>e.cid===v):-1,h=y&&b>=0?`${f.title} P${g[b].pageNumber} ${g[b].title}`:f.title,k=f.ugcSeason?.episodes??[],w=k.length>0?k.findIndex(e=>e.cid===v):-1;e.metadata=new MediaMetadata({title:h,artist:f.up.name,album:f.title,artwork:f.coverUrl?[{src:f.coverUrl,sizes:"480x270",type:"image/jpeg"}]:[]}),e.playbackState=m.paused?"paused":"playing",o("play",()=>{m.play()}),o("pause",()=>{m.pause()}),o("seekbackward",e=>{const t=e.seekOffset??10;n.playerAgent.changeTime(-t),d()}),o("seekforward",e=>{const t=e.seekOffset??10;n.playerAgent.changeTime(t),d()}),o("seekto",e=>{void 0!==e.seekTime&&(n.playerAgent.seek(e.seekTime),d())});let A=null,S=null;if(y&&b>0?A=()=>r(g[b-1].pageNumber):w>0&&(A=()=>s(k[w-1].bvid)),y&&br(g[b+1].pageNumber);else if(w>=0&&ws(k[w+1].bvid);else if(!y&&0===k.length){const e=document.querySelector(".bpx-player-ctrl-next");null===e||e.classList.contains("bpx-state-disabled")||(S=()=>e.click())}o("previoustrack",A),o("nexttrack",S);const E=()=>d();m.readyState>=HTMLMediaElement.HAVE_METADATA?E():(m.addEventListener("loadedmetadata",E,{once:!0}),c.push(()=>m.removeEventListener("loadedmetadata",E)));const P=()=>{e.playbackState="playing",d()},x=()=>{e.playbackState="paused",d()},L=()=>d();m.addEventListener("play",P),m.addEventListener("pause",x),m.addEventListener("ratechange",L),c.push(()=>m.removeEventListener("play",P),()=>m.removeEventListener("pause",x),()=>m.removeEventListener("ratechange",L)),l=setInterval(d,5e3)})},commitHash:"84a082518e541aa44fa0999a9eaab0af7e8f8eee",coreVersion:"2.10.7"};return t=t.plugin})()); \ No newline at end of file diff --git a/registry/lib/plugins/video/mpris/index.ts b/registry/lib/plugins/video/mpris/index.ts new file mode 100644 index 0000000000..32baee3298 --- /dev/null +++ b/registry/lib/plugins/video/mpris/index.ts @@ -0,0 +1,218 @@ +import type { PluginMetadata } from '@/plugins/plugin' +import { videoChange } from '@/core/observer' +import { playerAgent } from '@/components/video/player-agent' +import { VideoInfo } from '@/components/video/video-info' + +const POSITION_UPDATE_INTERVAL_MS = 5000 + +const setOrClearAction = ( + action: MediaSessionAction, + handler: MediaSessionActionHandler | null, +) => { + try { + navigator.mediaSession.setActionHandler(action, handler) + } catch { + // action may not be supported in this browser + } +} + +const navigateToPage = (pageNumber: number) => { + const url = new URL(window.location.href) + url.searchParams.set('p', String(pageNumber)) + window.location.href = url.toString() +} + +const navigateToBvid = (bvid: string) => { + window.location.href = `https://www.bilibili.com/video/${bvid}` +} + +export const plugin: PluginMetadata = { + name: 'video.mpris', + displayName: 'MPRIS 媒体控制', + description: `通过 Media Session API 将 Bilibili 视频集成到系统媒体控制(MPRIS): +- 标题为视频标题(多P时附带分P标题) +- 艺术家为 UP 主名称 +- 专辑封面为视频封面 +- 播放进度为视频进度 +- 支持快进、快退、倍速 +- 分P视频/合集支持上一曲和下一曲切换,并在切换视频时自动更新以上信息`, + setup: () => { + if (!('mediaSession' in navigator)) { + return + } + + const ms = navigator.mediaSession + let currentVideoElement: HTMLVideoElement | null = null + let positionUpdateTimer: ReturnType | null = null + let videoEventCleanups: (() => void)[] = [] + + const clearTimer = () => { + if (positionUpdateTimer !== null) { + clearInterval(positionUpdateTimer) + positionUpdateTimer = null + } + } + + const clearVideoEvents = () => { + videoEventCleanups.forEach(fn => fn()) + videoEventCleanups = [] + } + + const updatePositionState = () => { + const v = currentVideoElement + if (!v || isNaN(v.duration) || v.duration === 0) { + return + } + try { + ms.setPositionState({ + duration: v.duration, + position: Math.min(v.currentTime, v.duration), + playbackRate: v.playbackRate, + }) + } catch { + // setPositionState may not be supported + } + } + + videoChange(async ({ aid, cid }) => { + clearTimer() + clearVideoEvents() + + const videoEl = (await playerAgent.query.video.element()) as HTMLVideoElement | null + currentVideoElement = videoEl + if (!videoEl) { + return + } + + // Use VideoInfo API to retrieve all video metadata in one call + let info: VideoInfo + try { + info = await new VideoInfo(String(aid)).fetchInfo() + } catch { + return + } + + const cidNum = Number(cid) + + // --- 分P (multi-page) --- + const { pages } = info + const isMultiPage = pages.length > 1 + const currentPageIdx = isMultiPage ? pages.findIndex(p => p.cid === cidNum) : -1 + + // Display title: append part title when multi-page + const displayTitle = + isMultiPage && currentPageIdx >= 0 + ? `${info.title} P${pages[currentPageIdx].pageNumber} ${pages[currentPageIdx].title}` + : info.title + + // --- 合集 (ugc_season) --- + const seasonEpisodes = info.ugcSeason?.episodes ?? [] + const currentSeasonIdx = + seasonEpisodes.length > 0 ? seasonEpisodes.findIndex(e => e.cid === cidNum) : -1 + + // Set media metadata via VideoInfo fields + ms.metadata = new MediaMetadata({ + title: displayTitle, + artist: info.up.name, + album: info.title, + artwork: info.coverUrl + ? [{ src: info.coverUrl, sizes: '480x270', type: 'image/jpeg' }] + : [], + }) + ms.playbackState = videoEl.paused ? 'paused' : 'playing' + + // Playback control handlers + setOrClearAction('play', () => { + videoEl.play() + }) + setOrClearAction('pause', () => { + videoEl.pause() + }) + setOrClearAction('seekbackward', details => { + const offset = details.seekOffset ?? 10 + playerAgent.changeTime(-offset) + updatePositionState() + }) + setOrClearAction('seekforward', details => { + const offset = details.seekOffset ?? 10 + playerAgent.changeTime(offset) + updatePositionState() + }) + setOrClearAction('seekto', details => { + if (details.seekTime !== undefined) { + playerAgent.seek(details.seekTime) + updatePositionState() + } + }) + + // --- Previous / next track --- + // A video can be BOTH in a collection (合集) AND have multiple parts (分P). + // Navigation priority: move between parts first; when at the boundary of + // the current video's pages, move to the adjacent episode in the collection. + // If neither applies, fall back to the player's built-in next button. + + // Compute prev/next handlers independently so both dimensions can contribute. + let prevHandler: MediaSessionActionHandler | null = null + let nextHandler: MediaSessionActionHandler | null = null + + if (isMultiPage && currentPageIdx > 0) { + // There is a previous part within the current video + prevHandler = () => navigateToPage(pages[currentPageIdx - 1].pageNumber) + } else if (currentSeasonIdx > 0) { + // At first part (or single-page) of a collection episode — go to previous episode + prevHandler = () => navigateToBvid(seasonEpisodes[currentSeasonIdx - 1].bvid) + } + + if (isMultiPage && currentPageIdx < pages.length - 1) { + // There is a next part within the current video + nextHandler = () => navigateToPage(pages[currentPageIdx + 1].pageNumber) + } else if (currentSeasonIdx >= 0 && currentSeasonIdx < seasonEpisodes.length - 1) { + // At last part (or single-page) of a collection episode — go to next episode + nextHandler = () => navigateToBvid(seasonEpisodes[currentSeasonIdx + 1].bvid) + } else if (!isMultiPage && seasonEpisodes.length === 0) { + // Single video or bangumi — delegate to player's built-in next button if available. + // The selector targets the bpx player's next-video button; when absent or disabled + // the handler is left as null (no nexttrack action registered), which is intentional. + const nextBtn = document.querySelector('.bpx-player-ctrl-next') as HTMLElement | null + if (nextBtn !== null && !nextBtn.classList.contains('bpx-state-disabled')) { + nextHandler = () => nextBtn.click() + } + } + + setOrClearAction('previoustrack', prevHandler) + setOrClearAction('nexttrack', nextHandler) + + // Position state — wait for metadata if needed + const initPosition = () => updatePositionState() + if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) { + initPosition() + } else { + videoEl.addEventListener('loadedmetadata', initPosition, { once: true }) + videoEventCleanups.push(() => videoEl.removeEventListener('loadedmetadata', initPosition)) + } + + // Sync playback state and position on events + const onPlay = () => { + ms.playbackState = 'playing' + updatePositionState() + } + const onPause = () => { + ms.playbackState = 'paused' + updatePositionState() + } + const onRateChange = () => updatePositionState() + + videoEl.addEventListener('play', onPlay) + videoEl.addEventListener('pause', onPause) + videoEl.addEventListener('ratechange', onRateChange) + videoEventCleanups.push( + () => videoEl.removeEventListener('play', onPlay), + () => videoEl.removeEventListener('pause', onPause), + () => videoEl.removeEventListener('ratechange', onRateChange), + ) + + // Periodic position update every 5 s + positionUpdateTimer = setInterval(updatePositionState, POSITION_UPDATE_INTERVAL_MS) + }) + }, +} diff --git a/src/components/video/video-info.ts b/src/components/video/video-info.ts index 1357744eb8..51b1299eca 100644 --- a/src/components/video/video-info.ts +++ b/src/components/video/video-info.ts @@ -12,6 +12,26 @@ export interface VideoPageInfo { pageNumber: number } +export interface UgcSeasonEpisodeInfo { + aid: string + bvid: string + cid: number + title: string +} + +export interface UgcSeasonSectionInfo { + title: string + episodes: UgcSeasonEpisodeInfo[] +} + +export interface UgcSeasonInfo { + seasonId: number + title: string + sections: UgcSeasonSectionInfo[] + /** All episodes flattened across sections, in order */ + episodes: UgcSeasonEpisodeInfo[] +} + export interface VideoStat { view: number like: number @@ -39,6 +59,7 @@ export class VideoInfo { description: string up: UpInfo pages: VideoPageInfo[] + ugcSeason?: UgcSeasonInfo redirectUrl?: string stat: VideoStat @@ -85,6 +106,26 @@ export class VideoInfo { title: it.part, pageNumber: it.page, })) + const ugcSeasonData = data.ugc_season + if (ugcSeasonData) { + const sections: UgcSeasonSectionInfo[] = (ugcSeasonData.sections ?? []).map((s: any) => ({ + title: s.title ?? '', + episodes: (s.episodes ?? []).map((e: any) => ({ + aid: String(e.aid), + bvid: String(e.bvid), + cid: Number(e.cid), + title: String(e.title ?? ''), + })), + })) + this.ugcSeason = { + seasonId: ugcSeasonData.id, + title: ugcSeasonData.title ?? '', + sections, + episodes: sections.flatMap(s => s.episodes), + } + } else { + this.ugcSeason = undefined + } this.redirectUrl = data.redirect_url this.stat = data.stat return this